authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-08 20:52:06-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-08 17:52:06-07:00
log89c320df0b0884a8c4df1cf596564c6bc725eb54
tree020f899c6f00a2605cb3244acc3eac75f780b42a
parent7b2cf4374f5d92cc01eff482ad1af92ab7a87f41
signature Signed by PGP key B5690EEEBB952194

fix(server): stop Codex threads with queued follow-ups (#5762)


3 files changed, 85 insertions(+), 12 deletions(-)

apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts+49
......@@ -245,4 +245,53 @@ describe("CodexSessionRuntime collab integration", () => {
245245 yield* runtime.close;
246246 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
247247 );
248
249 it.live("Stop targets the active turn when Codex has accepted a queued follow-up", () =>
250 Effect.gen(function* () {
251 const activeTurnId = "019fe3e8-f908-7f31-8d51-283f4a47897a";
252 const queuedTurnId = "019fe3eb-8faf-7de3-a85b-ac64c7f9c8c3";
253 const script = {
254 rootThreadId: ROOT,
255 holdTurnOpen: true,
256 onlyFirstTurnStarts: true,
257 turnIds: [activeTurnId, queuedTurnId],
258 expectedActiveTurnId: activeTurnId,
259 notifications: [],
260 };
261 // @effect-diagnostics-next-line preferSchemaOverJson:off
262 NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8");
263 const interruptsPath = `${scriptPath}.interrupts`;
264 NodeFS.rmSync(interruptsPath, { force: true });
265 yield* Effect.addFinalizer(() =>
266 Effect.sync(() => {
267 NodeFS.rmSync(scriptPath, { force: true });
268 NodeFS.rmSync(interruptsPath, { force: true });
269 }),
270 );
271
272 const runtime = yield* makeCodexSessionRuntime({
273 threadId: ThreadId.make("thread-codex-queued-stop"),
274 binaryPath: peerPath,
275 cwd: "/tmp",
276 runtimeMode: "full-access",
277 environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath },
278 });
279
280 yield* runtime.start();
281 yield* runtime.sendTurn({ input: "keep working" });
282 yield* runtime.sendTurn({ input: "queued follow-up" });
283 yield* runtime.interruptTurn();
284
285 const interrupts = NodeFS.readFileSync(interruptsPath, "utf8")
286 .trim()
287 .split("\n")
288 .map((line) => JSON.parse(line) as { threadId?: string; turnId?: string });
289 assert.deepEqual(interrupts.at(-1), {
290 threadId: ROOT,
291 turnId: activeTurnId,
292 });
293
294 yield* runtime.close;
295 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
296 );
248297});
apps/server/src/provider/Layers/CodexSessionRuntime.ts+8-5
......@@ -814,13 +814,13 @@ function currentProviderThreadId(session: ProviderSession): string | undefined {
814814
815815function updateSession(
816816 sessionRef: Ref.Ref<ProviderSession>,
817 updates: Partial<ProviderSession>,
817 updates: Partial<ProviderSession> | ((session: ProviderSession) => Partial<ProviderSession>),
818818): Effect.Effect<void> {
819819 return Effect.gen(function* () {
820820 const updatedAt = DateTime.formatIso(yield* DateTime.now);
821821 yield* Ref.update(sessionRef, (session) => ({
822822 ...session,
823 ...updates,
823 ...(typeof updates === "function" ? updates(session) : updates),
824824 updatedAt,
825825 }));
826826 });
......@@ -1782,11 +1782,14 @@ export const makeCodexSessionRuntime = (
17821782 ),
17831783 );
17841784 const turnId = TurnId.make(response.turn.id);
1785 yield* updateSession(sessionRef, {
1785 yield* updateSession(sessionRef, (session) => ({
17861786 status: "running",
1787 activeTurnId: turnId,
1787 // Codex accepts follow-ups while the current turn is still
1788 // running. The response contains the queued turn id, but
1789 // turn/interrupt only accepts the id that is active now.
1790 activeTurnId: session.activeTurnId ?? turnId,
17881791 ...(normalizedModel ? { model: normalizedModel } : {}),
1789 });
1792 }));
17901793 const resumedProviderThreadId = currentProviderThreadId(yield* Ref.get(sessionRef));
17911794 return {
17921795 threadId: options.threadId,
apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs+28-7
......@@ -16,6 +16,7 @@ const fixture = JSON.parse(
1616const script = JSON.parse(NodeFS.readFileSync(process.env.T3_CODEX_COLLAB_SCRIPT, "utf8"));
1717
1818const write = (message) => process.stdout.write(`${JSON.stringify(message)}\n`);
19let turnStartCount = 0;
1920
2021const rl = NodeReadline.createInterface({ input: process.stdin });
2122rl.on("line", (line) => {
......@@ -43,14 +44,20 @@ rl.on("line", (line) => {
4344 return;
4445 }
4546 if (method === "turn/start") {
46 write({ id, result: fixture.responses.turnStart });
47 const turnId = script.turnIds?.[turnStartCount];
48 const turn = turnId
49 ? { ...fixture.responses.turnStart.turn, id: turnId }
50 : fixture.responses.turnStart.turn;
51 turnStartCount += 1;
52 write({ id, result: { ...fixture.responses.turnStart, turn } });
4753 const rootThreadId = script.rootThreadId;
48 const turn = fixture.responses.turnStart.turn;
49 write({
50 jsonrpc: "2.0",
51 method: "turn/started",
52 params: { threadId: rootThreadId, turn },
53 });
54 if (script.onlyFirstTurnStarts !== true || turnStartCount === 1) {
55 write({
56 jsonrpc: "2.0",
57 method: "turn/started",
58 params: { threadId: rootThreadId, turn },
59 });
60 }
5461 for (const notification of script.notifications) {
5562 write({ jsonrpc: "2.0", method: notification.method, params: notification.params });
5663 }
......@@ -75,6 +82,20 @@ rl.on("line", (line) => {
7582 `${process.env.T3_CODEX_COLLAB_SCRIPT}.interrupts`,
7683 `${JSON.stringify({ threadId: target, turnId: message.params?.turnId })}\n`,
7784 );
85 if (
86 script.expectedActiveTurnId &&
87 message.params?.threadId === script.rootThreadId &&
88 message.params?.turnId !== script.expectedActiveTurnId
89 ) {
90 write({
91 id,
92 error: {
93 code: -32000,
94 message: `expected active turn id ${message.params?.turnId} but found ${script.expectedActiveTurnId}`,
95 },
96 });
97 return;
98 }
7899 if (script.failInterruptFor && script.failInterruptFor === target) {
79100 write({ id, error: { code: -32000, message: "thread already closed" } });
80101 return;