Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 23 additions & 15 deletions scripts/codex-app-server-wake.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -125,10 +125,12 @@ export const readFinalAnswerFromSessionLog = (sessionPath, turnId) => {
return "";
};

export const buildThreadStartParams = (binding = null) => ({
model: binding?.model ?? null,
export const buildThreadStartParams = (binding = null, peer = null) => ({
model: binding?.model ?? peer?.model ?? null,
modelProvider: null,
cwd: null,
// A seeded thread must inherit the peer's working directory, otherwise Codex starts
// in `/` with no project instructions, wrong workspace roots and wrong permissions.
cwd: peer?.cwd ?? null,
runtimeWorkspaceRoots: null,
approvalPolicy: null,
approvalsReviewer: null,
Expand Down Expand Up @@ -531,28 +533,34 @@ export const createCodexAppServerInjector = ({ Client = CodexAppServerClient, lo
return client.request("turn/start", turnParams(threadId));
};

// A thread seeded in this call has no rollout file until its first turn, so
// `thread/resume` can only fail on it — and that failure is what used to leave
// `threadPath` null and silently disable the session-log completion fallback.
const seedThread = async (reason) => {
const started = await client.request("thread/start", buildThreadStartParams(threadStartBinding, peer));
const seededId = started?.thread?.id;
if (!seededId) throw new Error(`codex-app-server-thread-start-missing:${payload.from}`);
threadPath = started?.thread?.path || threadPath;
peer.threadId = seededId;
log("info", `Codex app-server wake thread ${reason}`, { msgId: payload.msgId, threadId: seededId, socketPath, threadPath });
return seededId;
};

let threadId = peer?.threadId;
let seededHere = false;
if (!threadId) {
const started = await client.request("thread/start", buildThreadStartParams(threadStartBinding));
threadId = started?.thread?.id;
if (!threadId) throw new Error(`codex-app-server-thread-start-missing:${payload.from}`);
peer.threadId = threadId;
log("info", "Codex app-server wake thread seeded", { msgId: payload.msgId, threadId, socketPath });
threadId = await seedThread("seeded");
seededHere = true;
}

let result;
try {
if (shouldResumeThread) await resumeThread(threadId);
if (shouldResumeThread && !seededHere) await resumeThread(threadId);
result = await startTurn(threadId);
} catch (err) {
const e = err instanceof Error ? err : new Error(String(err));
if (!e.message.startsWith("codex-app-server-error:thread not found:")) throw e;
const started = await client.request("thread/start", buildThreadStartParams(threadStartBinding));
threadId = started?.thread?.id;
if (!threadId) throw new Error(`codex-app-server-thread-start-missing:${payload.from}`);
peer.threadId = threadId;
log("info", "Codex app-server wake thread re-seeded", { msgId: payload.msgId, threadId, socketPath });
if (shouldResumeThread) await resumeThread(threadId);
threadId = await seedThread("re-seeded");
result = await startTurn(threadId);
}
log("info", "Codex app-server wake completed", { msgId: payload.msgId, threadId, socketPath });
Expand Down
10 changes: 10 additions & 0 deletions scripts/wake-monitor.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@ export const normalizeWakeConfig = (config = {}) => {
if (typeof value.dataDir === "string" && value.dataDir.trim()) normalized.dataDir = value.dataDir.trim();
if (typeof value.storePath === "string" && value.storePath.trim()) normalized.storePath = value.storePath.trim();
if (value.relayFinalToMurmur === true) normalized.relayFinalToMurmur = true;
// Without this the injector's `peer.resume === false` opt-out is unreachable
// from a real config: normalization used to drop the field entirely.
if (typeof value.resume === "boolean") normalized.resume = value.resume;
// Same story for baseInstructions: the channel binding resolver falls back to
// `peer.baseInstructions`, so dropping it here makes per-peer role instructions
// impossible to configure — the only remaining lever is `personaId`, which Codex
// rejects for anything outside its own `none|friendly|pragmatic` enum.
if (typeof value.baseInstructions === "string" && value.baseInstructions.trim()) {
normalized.baseInstructions = value.baseInstructions;
}
if (Number.isFinite(Number(value.replyTimeoutMs))) normalized.replyTimeoutMs = Number(value.replyTimeoutMs);
return [agentId, normalized];
}),
Expand Down
76 changes: 76 additions & 0 deletions tests/codex-app-server-wake.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,82 @@ test("normalizeWakeConfig preserves Codex reply relay peer settings", () => {
});
});

test("normalizeWakeConfig preserves the explicit resume opt-out", () => {
const config = normalizeWakeConfig({
wake: {
peers: {
"agent-jarvis": {
mode: "codex_app_server",
socketPath: "/tmp/codex.sock",
threadId: "thread-1",
resume: false,
},
},
},
});

assert.equal(config.peers["agent-jarvis"].resume, false);
});

test("normalizeWakeConfig preserves per-peer baseInstructions", () => {
const config = normalizeWakeConfig({
wake: {
peers: {
"agent-jarvis": {
mode: "codex_app_server",
socketPath: "/tmp/codex.sock",
baseInstructions: "You are the critic on this channel.",
},
},
},
});

assert.equal(config.peers["agent-jarvis"].baseInstructions, "You are the critic on this channel.");
});

test("buildThreadStartParams carries peer cwd and model into thread/start", () => {
const params = buildThreadStartParams(null, { cwd: "/work/project", model: "gpt-5.6-sol" });

assert.equal(params.cwd, "/work/project");
assert.equal(params.model, "gpt-5.6-sol");
});

test("Codex app-server injector keeps the seeded thread path and skips resume", async () => {
const calls = [];
class FakeClient {
async request(method, params) {
calls.push({ method, params });
if (method === "thread/start") {
return { thread: { id: "fresh-thread", path: "/tmp/rollout-fresh.jsonl" } };
}
return {};
}

async startTurnAndWaitForFinal(params, options) {
calls.push({ method: "turn/start:wait", params, options });
return { finalText: "", turnId: "turn-1" };
}
}

const peer = {
mode: "codex_app_server",
socketPath: "/tmp/codex.sock",
cwd: "/work/project",
relayFinalToMurmur: true,
murmurRoot: "/work/murmur",
dataDir: "/work/.data",
storePath: "/work/.data/murmur.db",
};
const injector = createCodexAppServerInjector({ Client: FakeClient });

await injector(payload, peer);

// A thread created right here has no rollout file yet: resuming it can only fail.
assert.deepEqual(calls.map((call) => call.method), ["thread/start", "turn/start:wait"]);
assert.equal(calls[0].params.cwd, "/work/project");
assert.equal(calls[1].options.sessionPath, "/tmp/rollout-fresh.jsonl");
});

test("buildTurnStartRequest builds Codex turn/start params", () => {
const request = buildTurnStartRequest({
id: 7,
Expand Down