From e4eb8083a35cd8ef9c5b92665dae7124d3ef8704 Mon Sep 17 00:00:00 2001 From: Alexander Shalaev Date: Mon, 27 Jul 2026 22:45:49 +0300 Subject: [PATCH 1/2] =?UTF-8?q?fix(codex-wake):=20keep=20seeded=20thread?= =?UTF-8?q?=20usable=20=E2=80=94=20carry=20cwd,=20keep=20path,=20skip=20re?= =?UTF-8?q?sume?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A thread seeded inside the wake call has no rollout file until its first turn, so thread/resume always failed on it with 'no rollout found'. That failure left threadPath null, which silently disabled the session-log completion fallback — the only path that actually completes a turn on Codex CLI 0.145. Result: Codex answered, the answer sat in the session JSONL, and the daemon timed out without relaying it. - buildThreadStartParams now carries peer cwd/model, so a seeded thread no longer starts in / with no project instructions or workspace roots - the thread/start response path is kept as threadPath (it was discarded) - a freshly seeded thread is no longer resumed; re-seed shares one code path - normalizeWakeConfig preserves the resume flag, which it dropped entirely, making the injector's resume opt-out unreachable from real config Tests cover the gap that broke in production: relayFinalToMurmur with no threadId configured. Refs #96 Co-Authored-By: Claude Opus 5 --- scripts/codex-app-server-wake.mjs | 38 +++++++++++------- scripts/wake-monitor.mjs | 3 ++ tests/codex-app-server-wake.test.mjs | 60 ++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 15 deletions(-) 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..ad5ed7b 100644 --- a/scripts/wake-monitor.mjs +++ b/scripts/wake-monitor.mjs @@ -21,6 +21,9 @@ 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; 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..52fd897 100644 --- a/tests/codex-app-server-wake.test.mjs +++ b/tests/codex-app-server-wake.test.mjs @@ -78,6 +78,66 @@ 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("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, From 94c93b43a6c76e06b2303cb4a9246b701de3c808 Mon Sep 17 00:00:00 2001 From: Alexander Shalaev Date: Mon, 27 Jul 2026 23:21:41 +0300 Subject: [PATCH 2/2] fix(wake-config): stop dropping per-peer baseInstructions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The channel binding resolver falls back to peer.baseInstructions when no baseInstructionsResolver is injected — which is exactly how murmur-daemon wires it. normalizeWakeConfig dropped the field, so per-peer role instructions were impossible to set from a real config. That left personaId as the only lever, and Codex CLI 0.145 rejects anything outside its own enum: codex-app-server-error:Invalid request: unknown variant `critic`, expected one of `none`, `friendly`, `pragmatic` So channel roles could not be expressed at all: free-form personas are refused by the server and instructions never reached it. With this change a peer can carry role instructions and the binding logs hasBaseInstructions: true. Refs #96 Co-Authored-By: Claude Opus 5 --- scripts/wake-monitor.mjs | 7 +++++++ tests/codex-app-server-wake.test.mjs | 16 ++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/scripts/wake-monitor.mjs b/scripts/wake-monitor.mjs index ad5ed7b..3526221 100644 --- a/scripts/wake-monitor.mjs +++ b/scripts/wake-monitor.mjs @@ -24,6 +24,13 @@ export const normalizeWakeConfig = (config = {}) => { // 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 52fd897..536e655 100644 --- a/tests/codex-app-server-wake.test.mjs +++ b/tests/codex-app-server-wake.test.mjs @@ -95,6 +95,22 @@ test("normalizeWakeConfig preserves the explicit resume opt-out", () => { 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" });