diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 40b7709870585..557160816bbb1 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -4676,7 +4676,8 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { ); const settledPayload = (settledAt: string | null, archivedAt: string | null) => sql` UPDATE orchestration_v2_projection_threads - SET payload_json = json_set(payload_json, '$.settledAt', ${settledAt}, '$.archivedAt', ${archivedAt}) + SET payload_json = json_set(payload_json, '$.settledAt', ${settledAt}, '$.archivedAt', ${archivedAt}), + archived_at = ${archivedAt} WHERE thread_id = ${readThreadId} `; yield* settledPayload(DateTime.formatIso(now), null); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 0c4976dc90598..89183b48a8ef4 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -678,6 +678,10 @@ export interface ProjectionStoreV2Shape { threadId: ThreadId, runId: RunId, ) => Effect.Effect; + readonly getRun: ( + threadId: ThreadId, + runId: RunId, + ) => Effect.Effect; readonly canStartQueuedRun: ( threadId: ThreadId, ) => Effect.Effect; @@ -5588,6 +5592,21 @@ export const layer: Layer.Layer = ) .pipe(Effect.mapError(controlReadError(threadId))); + const getRun: ProjectionStoreV2Shape["getRun"] = (threadId, runId) => + sql + .withTransaction( + Effect.gen(function* () { + yield* requireThread(threadId); + const rows = yield* sql` + SELECT payload_json FROM orchestration_v2_projection_runs + WHERE run_id = ${runId} AND thread_id = ${threadId}`; + return rows[0] === undefined + ? undefined + : yield* decodeRunPayload(rows[0].payload_json); + }), + ) + .pipe(Effect.mapError(controlReadError(threadId))); + const canStartQueuedRun: ProjectionStoreV2Shape["canStartQueuedRun"] = (threadId) => sql .withTransaction( @@ -7218,7 +7237,7 @@ export const layer: Layer.Layer = SELECT t.payload_json FROM orchestration_v2_projection_threads t WHERE t.deleted_at IS NULL${ threadId === undefined - ? sql` AND json_extract(t.payload_json, '$.archivedAt') IS NULL` + ? sql` AND t.archived_at IS NULL` : sql` AND t.thread_id = ${threadId}` }${ unsettledOnly @@ -7639,6 +7658,7 @@ export const layer: Layer.Layer = getCheckpointCaptureContext: readOnReader(getCheckpointCaptureContext), getCheckpointRollbackContext: readOnReader(getCheckpointRollbackContext), getRunMessage: readOnReader(getRunMessage), + getRun: readOnReader(getRun), canStartQueuedRun: readOnReader(canStartQueuedRun), getHeldQueuedRuns: readOnReader(getHeldQueuedRuns), getPendingNativeUserInputs: readOnReader(getPendingNativeUserInputs), diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts index 8b30a961c099d..25daa45a847fa 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts @@ -268,6 +268,7 @@ it.effect( getCheckpointRollbackContext: () => Effect.die("unused rollback context"), getCheckpointCaptureContext: () => Effect.die("not used"), getRunMessage: () => Effect.die("not used"), + getRun: () => Effect.die("not used"), isNodeStored: () => Effect.die("not used"), canStartQueuedRun: () => Effect.die("not used"), getHeldQueuedRuns: () => Effect.die("not used"), diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index 7c58e6ad079e8..a4f0149551a2a 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -919,6 +919,18 @@ function makeLocalCommandHarness(input: { ), }), ); + const getRuntimeRecoveryProjection = vi.fn< + ProjectionStore.ProjectionStoreV2Shape["getRuntimeRecoveryProjection"] + >(() => + Effect.as(failReadIfRunning, { + ...projection, + hasConversation: projection.messages.some( + (m) => + m.role === "user" && + (m.text.trim().toLowerCase() !== "/compact" || m.attachments.length > 0), + ), + }), + ); const layer = ProviderTurnStart.layer.pipe( Layer.provide( Layer.mergeAll( @@ -968,15 +980,12 @@ function makeLocalCommandHarness(input: { Layer.mock(ProjectionStore.ProjectionStoreV2)({ getThreadRecords, getTurnStartContext, - getRuntimeRecoveryProjection: () => - Effect.as(failReadIfRunning, { - ...projection, - hasConversation: projection.messages.some( - (m) => - m.role === "user" && - (m.text.trim().toLowerCase() !== "/compact" || m.attachments.length > 0), - ), - }), + getRuntimeRecoveryProjection, + getRun: (_threadId, id) => + Effect.as( + failReadIfRunning, + projection.runs.find((candidate) => candidate.id === id), + ), getTurnStartHistory: () => input.sideChat !== undefined ? Effect.succeed([]) @@ -1017,6 +1026,7 @@ function makeLocalCommandHarness(input: { settledRuns, getThreadRecords, getTurnStartContext, + getRuntimeRecoveryProjection, setProjection: (next: OrchestrationV2ThreadProjection) => { projection = next; }, @@ -1764,6 +1774,29 @@ effectIt.effect("does not overwrite a run interrupted while its provider session }), ); +effectIt.effect("checks the run's status without loading the thread's history", () => + Effect.gen(function* () { + const harness = makeLocalCommandHarness({ + text: "Continue", + previousTurn: { runStatus: "completed", turnStatus: "completed" }, + }); + + yield* harness.start; + + const controls = harness.startRootRun.mock.calls[0]?.[0]; + expect(controls).toBeDefined(); + if (controls === undefined) return; + expect(yield* controls.shouldStartProviderTurn!()).toBe(true); + expect(yield* controls.shouldFinalizeRun!()).toBe(true); + // Each check needs one run row; the recovery projection reads every run and open turn item. + // The one read left is the start's inherited background items snapshot, not a status check. + expect( + harness.getRuntimeRecoveryProjection.mock.calls.length, + "recovery projection reads beyond the inherited background items snapshot", + ).toBe(1); + }), +); + effectIt.effect("fails the run instead of replacing a native thread another client holds", () => Effect.gen(function* () { const detail = "This conversation is open in another Codex client."; diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 98652036118fc..a9e0b3f29b3aa 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -237,13 +237,16 @@ export const layer: Layer.Layer< typeof RunExecutionService.selectInheritedBackgroundTurnItems >; }) => { + const isCurrentAttemptIn = (statuses: ReadonlyArray) => + projectionStore + .getRun(input.threadId, input.runId) + .pipe( + Effect.map( + (run) => run?.activeAttemptId === input.attemptId && statuses.includes(run.status), + ), + ); const isCurrentAttemptInStatus = (expectedStatus: OrchestrationV2Run["status"]) => - projectionStore.getRuntimeRecoveryProjection(input.threadId).pipe( - Effect.map((current) => { - const run = current.runs.find((candidate) => candidate.id === input.runId); - return run?.activeAttemptId === input.attemptId && run.status === expectedStatus; - }), - ); + isCurrentAttemptIn([expectedStatus]); return { isCurrentAttemptInStatus, loadInheritedBackgroundTurnItems: () => @@ -260,16 +263,7 @@ export const layer: Layer.Layer< Effect.catchCause(() => Effect.succeed(input.inheritedBackgroundTurnItems)), ), shouldStartProviderTurn: () => isCurrentAttemptInStatus("running"), - shouldFinalizeRun: () => - projectionStore.getRuntimeRecoveryProjection(input.threadId).pipe( - Effect.map((current) => { - const run = current.runs.find((candidate) => candidate.id === input.runId); - return ( - run?.activeAttemptId === input.attemptId && - (run.status === "starting" || run.status === "running") - ); - }), - ), + shouldFinalizeRun: () => isCurrentAttemptIn(["starting", "running"]), hasUnpairedRunInterruptRequest: () => projectionStore .hasUnpairedRunInterruptRequest(