Skip to content

Commit 699856a

Browse files
tim-smartgithub-actions[bot]
authored andcommitted
Skip stale restart recovery for settled turns
- Clear stale recovery bindings when the projected turn is already settled - Preserve pending turn-start recovery unless restart recovery actually begins
1 parent a089dd5 commit 699856a

2 files changed

Lines changed: 196 additions & 8 deletions

File tree

apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -871,6 +871,147 @@ describe("ProviderCommandReactor", () => {
871871
});
872872
});
873873

874+
it("does not resume settled turns from stale recovery bindings", async () => {
875+
const modelSelection: ModelSelection = {
876+
instanceId: ProviderInstanceId.make("codex"),
877+
model: "gpt-5-codex",
878+
};
879+
const markerThreadId = ThreadId.make("thread-1");
880+
const legacyThreadId = ThreadId.make("thread-settled-legacy");
881+
const markerTurnId = asTurnId("turn-settled-marker");
882+
const legacyTurnId = asTurnId("turn-settled-legacy");
883+
const harness = await createHarness({
884+
deferReactorStart: true,
885+
providerBindings: [
886+
{
887+
threadId: markerThreadId,
888+
provider: ProviderDriverKind.make("codex"),
889+
providerInstanceId: ProviderInstanceId.make("codex"),
890+
runtimeMode: "full-access",
891+
status: "stopped",
892+
resumeCursor: { threadId: "provider-thread-marker" },
893+
runtimePayload: {
894+
modelSelection,
895+
restartRecovery: makeProviderRestartRecoveryMarker({
896+
interruptedProviderTurnId: markerTurnId,
897+
shutdownAt: "2026-01-01T00:00:02.000Z",
898+
}),
899+
},
900+
lastSeenAt: "2026-01-01T00:00:02.000Z",
901+
},
902+
{
903+
threadId: legacyThreadId,
904+
provider: ProviderDriverKind.make("codex"),
905+
providerInstanceId: ProviderInstanceId.make("codex"),
906+
runtimeMode: "full-access",
907+
status: "running",
908+
resumeCursor: { threadId: "provider-thread-legacy" },
909+
runtimePayload: {
910+
modelSelection,
911+
activeTurnId: legacyTurnId,
912+
},
913+
lastSeenAt: "2026-01-01T00:00:02.000Z",
914+
},
915+
],
916+
});
917+
await Effect.runPromise(
918+
harness.engine.dispatch({
919+
type: "thread.create",
920+
commandId: CommandId.make("cmd-create-settled-legacy-thread"),
921+
threadId: legacyThreadId,
922+
projectId: asProjectId("project-1"),
923+
title: "Settled legacy thread",
924+
modelSelection,
925+
interactionMode: "default",
926+
runtimeMode: "full-access",
927+
branch: null,
928+
worktreePath: null,
929+
createdAt: "2026-01-01T00:00:00.000Z",
930+
}),
931+
);
932+
933+
const settleTurn = async (threadId: ThreadId, turnId: TurnId, suffix: string) => {
934+
await Effect.runPromise(
935+
harness.engine.dispatch({
936+
type: "thread.turn.start",
937+
commandId: CommandId.make(`cmd-settled-turn-${suffix}`),
938+
threadId,
939+
message: {
940+
messageId: asMessageId(`message-settled-${suffix}`),
941+
role: "user",
942+
text: "already finished",
943+
attachments: [],
944+
},
945+
modelSelection,
946+
interactionMode: "default",
947+
runtimeMode: "full-access",
948+
createdAt: "2026-01-01T00:00:00.500Z",
949+
}),
950+
);
951+
await Effect.runPromise(
952+
harness.engine.dispatch({
953+
type: "thread.session.set",
954+
commandId: CommandId.make(`server:settled-running-${suffix}`),
955+
threadId,
956+
session: {
957+
threadId,
958+
status: "running",
959+
providerName: "codex",
960+
providerInstanceId: ProviderInstanceId.make("codex"),
961+
runtimeMode: "full-access",
962+
activeTurnId: turnId,
963+
lastError: null,
964+
updatedAt: "2026-01-01T00:00:01.000Z",
965+
},
966+
createdAt: "2026-01-01T00:00:01.000Z",
967+
}),
968+
);
969+
await Effect.runPromise(
970+
harness.engine.dispatch({
971+
type: "thread.session.set",
972+
commandId: CommandId.make(`server:settled-ready-${suffix}`),
973+
threadId,
974+
session: {
975+
threadId,
976+
status: "ready",
977+
providerName: "codex",
978+
providerInstanceId: ProviderInstanceId.make("codex"),
979+
runtimeMode: "full-access",
980+
activeTurnId: null,
981+
lastError: null,
982+
updatedAt: "2026-01-01T00:00:01.500Z",
983+
},
984+
createdAt: "2026-01-01T00:00:01.500Z",
985+
}),
986+
);
987+
};
988+
989+
await settleTurn(markerThreadId, markerTurnId, "marker");
990+
await settleTurn(legacyThreadId, legacyTurnId, "legacy");
991+
expect(
992+
(await harness.readTurns(markerThreadId)).find((turn) => turn.turnId === markerTurnId)?.state,
993+
).toBe("completed");
994+
expect(
995+
(await harness.readTurns(legacyThreadId)).find((turn) => turn.turnId === legacyTurnId)?.state,
996+
).toBe("completed");
997+
998+
await harness.startReactor();
999+
await harness.drain();
1000+
1001+
expect(harness.startSession).not.toHaveBeenCalled();
1002+
expect(harness.sendTurn).not.toHaveBeenCalled();
1003+
for (const threadId of [markerThreadId, legacyThreadId]) {
1004+
expect(harness.providerBindings.get(threadId)).toMatchObject({
1005+
status: "stopped",
1006+
runtimePayload: {
1007+
activeTurnId: null,
1008+
restartRecovery: null,
1009+
lastRuntimeEvent: "provider.restartRecovery.skipped",
1010+
},
1011+
});
1012+
}
1013+
});
1014+
8741015
it("recovers across temporary-SQLite runtimes and does not repeat a completed recovery", async () => {
8751016
const baseDir = NodeFS.mkdtempSync(
8761017
NodePath.join(NodeOS.tmpdir(), "t3code-reactor-two-runtime-"),

apps/server/src/orchestration/Layers/ProviderCommandReactor.ts

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,7 @@ const make = Effect.gen(function* () {
290290
});
291291
const startupReconciliationDone = yield* Deferred.make<void>();
292292
const recoveredThreadIds = new Set<ThreadId>();
293+
const interruptedRecoveryThreadIds = new Set<ThreadId>();
293294

294295
const hasHandledTurnStartRecently = (key: string) =>
295296
Cache.getOption(handledTurnStartKeys, key).pipe(
@@ -1339,6 +1340,56 @@ const make = Effect.gen(function* () {
13391340
}
13401341

13411342
const createdAt = DateTime.formatIso(yield* DateTime.now);
1343+
const projectedTurns = yield* projectionTurnRepository.listByThreadId({
1344+
threadId: binding.threadId,
1345+
});
1346+
const projectedCandidateTurn =
1347+
candidate.interruptedProviderTurnId === null
1348+
? undefined
1349+
: projectedTurns.find((turn) => turn.turnId === candidate.interruptedProviderTurnId);
1350+
const latestProjectedTurn = projectedTurns.findLast((turn) => turn.turnId !== null);
1351+
const projectedRecoveryTurn = projectedCandidateTurn ?? latestProjectedTurn;
1352+
if (projectedRecoveryTurn?.state === "completed" || projectedRecoveryTurn?.state === "error") {
1353+
yield* providerSessionDirectory
1354+
.upsert({
1355+
threadId: binding.threadId,
1356+
provider: binding.provider,
1357+
...(binding.providerInstanceId !== undefined
1358+
? { providerInstanceId: binding.providerInstanceId }
1359+
: {}),
1360+
...(binding.runtimeMode !== undefined ? { runtimeMode: binding.runtimeMode } : {}),
1361+
status: "stopped",
1362+
runtimePayload: {
1363+
activeTurnId: null,
1364+
restartRecovery: null,
1365+
lastRuntimeEvent: "provider.restartRecovery.skipped",
1366+
lastRuntimeEventAt: createdAt,
1367+
},
1368+
})
1369+
.pipe(
1370+
Effect.catchCause((cause) =>
1371+
Effect.logWarning("stale provider restart recovery intent was not cleared", {
1372+
threadId: binding.threadId,
1373+
cause: Cause.pretty(cause),
1374+
}),
1375+
),
1376+
);
1377+
yield* Effect.logInfo("provider turn restart recovery skipped", {
1378+
threadId: binding.threadId,
1379+
provider: binding.provider,
1380+
reason: "projected-turn-settled",
1381+
projectedTurnId: projectedRecoveryTurn.turnId,
1382+
projectedTurnState: projectedRecoveryTurn.state,
1383+
});
1384+
yield* increment(providerTurnRecoveriesTotal, {
1385+
outcome: "skipped",
1386+
reason: "projected-turn-settled",
1387+
provider: binding.provider,
1388+
});
1389+
return;
1390+
}
1391+
interruptedRecoveryThreadIds.add(thread.id);
1392+
13421393
const recover = Effect.gen(function* () {
13431394
const runtimeMode = binding.runtimeMode ?? thread.runtimeMode;
13441395
// This lifecycle transition settles the concrete old projection row as
@@ -1623,10 +1674,6 @@ const make = Effect.gen(function* () {
16231674
});
16241675
return candidate === undefined ? [] : [{ binding, candidate }];
16251676
});
1626-
const recoveryCandidateThreadIds = new Set(
1627-
recoveryCandidates.map(({ binding }) => binding.threadId),
1628-
);
1629-
16301677
const pendingTurnStarts = yield* projectionTurnRepository.listPendingTurnStarts().pipe(
16311678
Effect.catchCause((cause) =>
16321679
Effect.logWarning("provider restart recovery failed to list pending turn starts", {
@@ -1652,10 +1699,10 @@ const make = Effect.gen(function* () {
16521699
discard: true,
16531700
});
16541701

1655-
const pendingWithoutAcceptedProviderTurn = pendingTurnStarts.filter(
1656-
(pending) => !recoveryCandidateThreadIds.has(pending.threadId),
1702+
const pendingWithoutInterruptedRecovery = pendingTurnStarts.filter(
1703+
(pending) => !interruptedRecoveryThreadIds.has(pending.threadId),
16571704
);
1658-
if (pendingWithoutAcceptedProviderTurn.length === 0) return;
1705+
if (pendingWithoutInterruptedRecovery.length === 0) return;
16591706

16601707
const persistedEvents = yield* Stream.runCollect(
16611708
orchestrationEngine.readEvents(0, Number.MAX_SAFE_INTEGER),
@@ -1680,7 +1727,7 @@ const make = Effect.gen(function* () {
16801727
}
16811728

16821729
yield* Effect.forEach(
1683-
pendingWithoutAcceptedProviderTurn,
1730+
pendingWithoutInterruptedRecovery,
16841731
(pending) =>
16851732
Effect.gen(function* () {
16861733
const thread = yield* resolveThread(pending.threadId);

0 commit comments

Comments
 (0)