Skip to content

Commit 0146592

Browse files
fix(server): commands a lost Codex app-server never finished no longer show as running
When the app-server dies or stops answering, the adapter now fails every command, dynamic tool, and MCP app capture it still tracks — for active turns and for settled turns with background work — before failing the event stream. Previously the run failed but its command item and node stayed running, so the composer kept a Running chip with a Stop button for work that no longer existed. The terminalization is the same triplet the interrupt path already runs, so both now go through one `terminalizeTrackedTurnWork` helper instead of repeating the three calls (and the opaque persistent-tools flag) at each site. The test that used a Codex exit to leave a stale command for Stop now asserts that the exit itself fails the command. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 355acfd commit 0146592

2 files changed

Lines changed: 78 additions & 31 deletions

File tree

‎apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts‎

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2013,6 +2013,23 @@ NodeReadline.createInterface({ input: process.stdin }).on("line", (line) => {
20132013
};
20142014
write({ id, result: { turn } });
20152015
write({ method: "turn/started", params: { threadId: params.threadId, turn } });
2016+
write({
2017+
method: "item/started",
2018+
params: {
2019+
threadId: params.threadId,
2020+
turnId: turn.id,
2021+
startedAtMs: Date.now(),
2022+
item: {
2023+
type: "commandExecution",
2024+
id: \`command-\${turn.id}\`,
2025+
command: "sleep 600",
2026+
cwd: process.cwd(),
2027+
status: "inProgress",
2028+
commandActions: [],
2029+
aggregatedOutput: null,
2030+
},
2031+
},
2032+
});
20162033
break;
20172034
}
20182035
case "thread/loaded/list":
@@ -2166,6 +2183,13 @@ NodeReadline.createInterface({ input: process.stdin }).on("line", (line) => {
21662183
const projection = yield* orchestrator.getThreadProjection(threadId);
21672184
assert.equal(projection.runs.at(-1)?.status, "failed");
21682185
assert.equal(projection.providerSessions.at(-1)?.status, "error");
2186+
// Nothing will complete the command the process took with it.
2187+
const command = projection.turnItems.find((item) => item.type === "command_execution");
2188+
assert.equal(command?.status, "failed");
2189+
assert.equal(
2190+
projection.nodes.find((node) => node.id === command?.nodeId)?.status,
2191+
"failed",
2192+
);
21692193
}
21702194

21712195
yield* createAndStart(ThreadId.make("thread:codex-recovery-new"));
@@ -5204,7 +5228,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {
52045228
);
52055229
});
52065230

5207-
it.effect("Stop ends a background command no Codex process tracks any more", () =>
5231+
it.effect("fails a background command its exited Codex process never finished", () =>
52085232
Effect.scoped(
52095233
Effect.gen(function* () {
52105234
const fs = yield* FileSystem.FileSystem;
@@ -5266,6 +5290,16 @@ describe("CodexAdapterV2 post-settle continuation", () => {
52665290
Stream.runHead,
52675291
Effect.forkChild({ startImmediately: true }),
52685292
);
5293+
const commandFailed = yield* orchestrator.streamDomainEvents.pipe(
5294+
Stream.filter(
5295+
(event) =>
5296+
event.type === "turn-item.updated" &&
5297+
event.payload.type === "command_execution" &&
5298+
event.payload.status === "failed",
5299+
),
5300+
Stream.runHead,
5301+
Effect.forkChild({ startImmediately: true }),
5302+
);
52695303
yield* orchestrator.dispatch({
52705304
type: "message.dispatch",
52715305
commandId: CommandId.make("start-background-stop-untracked"),
@@ -5279,28 +5313,14 @@ describe("CodexAdapterV2 post-settle continuation", () => {
52795313
});
52805314
yield* worker.drain();
52815315
yield* Fiber.join(settled);
5282-
yield* worker.drain();
5283-
const before = yield* orchestrator.getThreadShell(threadId);
5284-
assert.deepEqual(
5285-
before?.pendingBackgroundTasks?.map((task) => task.kind),
5286-
["command"],
5287-
"the thread still shows the command the gone process never finished",
5288-
);
5289-
const run = (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)!;
5290-
yield* orchestrator.dispatch({
5291-
type: "run.interrupt",
5292-
commandId: CommandId.make("stop-background-untracked"),
5293-
threadId,
5294-
runId: run.id,
5295-
holdQueue: true,
5296-
});
5316+
yield* Fiber.join(commandFailed);
52975317
yield* worker.drain();
52985318
const projection = yield* orchestrator.getThreadProjection(threadId);
52995319
assert.deepEqual(
53005320
projection.turnItems.flatMap((item) =>
53015321
item.type === "command_execution" ? [item.status] : [],
53025322
),
5303-
["interrupted"],
5323+
["failed"],
53045324
);
53055325
assert.equal(projection.runs.at(-1)?.status, "completed");
53065326
assert.deepEqual(

‎apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts‎

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3844,6 +3844,32 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
38443844
}
38453845
});
38463846

3847+
// Terminalizes everything a turn still tracks — running command items,
3848+
// dynamic tools (persistent ones included, since the turn itself is
3849+
// ending), and pending MCP app captures — to one terminal status.
3850+
// Shared by the interrupt path and the lost-app-server path.
3851+
const terminalizeTrackedTurnWork = (
3852+
context: ActiveCodexTurnContext,
3853+
status: "interrupted" | "failed",
3854+
completedAt: DateTime.Utc,
3855+
) =>
3856+
Effect.gen(function* () {
3857+
yield* terminalizeRunningCommandItems(
3858+
context,
3859+
context.nativeTurnId,
3860+
status,
3861+
completedAt,
3862+
);
3863+
yield* terminalizeRunningDynamicTools(
3864+
context,
3865+
context.nativeTurnId,
3866+
status,
3867+
completedAt,
3868+
true,
3869+
);
3870+
yield* terminalizeMcpAppCaptures(context, context.nativeTurnId);
3871+
});
3872+
38473873
const buildProposedPlanArtifacts = (input: {
38483874
readonly context: ActiveCodexTurnContext;
38493875
readonly nativeItemId: string;
@@ -6429,11 +6455,25 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
64296455
}
64306456
}
64316457
});
6458+
// No item/completed will arrive for work the process took with it,
6459+
// including background commands of turns that already completed.
6460+
const failLostTurnWork = turnTerminalizationPermit.withPermits(1)(
6461+
Effect.gen(function* () {
6462+
const completedAt = yield* DateTime.now;
6463+
for (const context of [
6464+
...(yield* Ref.get(activeTurns)).values(),
6465+
...(yield* Ref.get(settledTurns)).values(),
6466+
]) {
6467+
yield* terminalizeTrackedTurnWork(context, "failed", completedAt);
6468+
}
6469+
}),
6470+
);
64326471
yield* Effect.raceFirst(
64336472
client.terminated,
64346473
// A probe that fails with a transport error is the termination above.
64356474
unresponsive.pipe(Effect.catch(() => Effect.never)),
64366475
).pipe(
6476+
Effect.tap(() => failLostTurnWork),
64376477
Effect.flatMap((cause) =>
64386478
Queue.fail(
64396479
events,
@@ -7078,20 +7118,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
70787118
const context = target.context;
70797119
if ((yield* Ref.get(settledTurns)).get(context.nativeTurnId) !== context)
70807120
continue;
7081-
yield* terminalizeRunningCommandItems(
7082-
context,
7083-
context.nativeTurnId,
7084-
"interrupted",
7085-
completedAt,
7086-
);
7087-
yield* terminalizeRunningDynamicTools(
7088-
context,
7089-
context.nativeTurnId,
7090-
"interrupted",
7091-
completedAt,
7092-
true,
7093-
);
7094-
yield* terminalizeMcpAppCaptures(context, context.nativeTurnId);
7121+
yield* terminalizeTrackedTurnWork(context, "interrupted", completedAt);
70957122
yield* Ref.update(runningCommandItemsByTurn, (current) => {
70967123
const updated = new Map(current);
70977124
updated.delete(context.nativeTurnId);

0 commit comments

Comments
 (0)