diff --git a/scripts/codex-app-server-wake.mjs b/scripts/codex-app-server-wake.mjs index 50cd383..23f58d7 100644 --- a/scripts/codex-app-server-wake.mjs +++ b/scripts/codex-app-server-wake.mjs @@ -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, @@ -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 }); diff --git a/scripts/wake-monitor.mjs b/scripts/wake-monitor.mjs index 316c8a6..3526221 100644 --- a/scripts/wake-monitor.mjs +++ b/scripts/wake-monitor.mjs @@ -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]; }), diff --git a/tests/codex-app-server-wake.test.mjs b/tests/codex-app-server-wake.test.mjs index b42ddb3..536e655 100644 --- a/tests/codex-app-server-wake.test.mjs +++ b/tests/codex-app-server-wake.test.mjs @@ -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,