| author | |
| committer | |
| log | a7a2230c33fbec9d6616a5de26f09e1fa8856c05 |
| tree | 8df259fe3fa9332b330903604dc59c7000971570 |
| parent | 0290c4a04e52ca4e93be7a6fb94c14bcc7b53728 |
| signature | Signed by PGP key B5690EEEBB952194 |
10 files changed, 623 insertions(+), 50 deletions(-)
apps/server/src/orchestration-v2/BackgroundWorkStop.integration.test.ts+193| ... | @@ -31,6 +31,7 @@ import type { | ... | @@ -31,6 +31,7 @@ import type { |
| 31 | ProviderAdapterV2TurnInput, | 31 | ProviderAdapterV2TurnInput, |
| 32 | } from "./ProviderAdapter.ts"; | 32 | } from "./ProviderAdapter.ts"; |
| 33 | import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; | 33 | import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; |
| 34 | import * as ProviderSessionManager from "./ProviderSessionManager.ts"; | ||
| 34 | import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; | 35 | import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; |
| 35 | import { checkpointWorkspace } from "./testkit/ReplayFixtureWorkspace.ts"; | 36 | import { checkpointWorkspace } from "./testkit/ReplayFixtureWorkspace.ts"; |
| 36 | 37 | ||
| ... | @@ -45,10 +46,17 @@ const stopEarlierBackgroundWork = ({ | ... | @@ -45,10 +46,17 @@ const stopEarlierBackgroundWork = ({ |
| 45 | failedStart = false, | 46 | failedStart = false, |
| 46 | stopWithQueue, | 47 | stopWithQueue, |
| 47 | olderStart = false, | 48 | olderStart = false, |
| 49 | stalledRun, | ||
| 48 | }: { | 50 | }: { |
| 49 | readonly failedStart?: boolean; | 51 | readonly failedStart?: boolean; |
| 50 | readonly stopWithQueue?: "thread.stop" | "run.interrupt"; | 52 | readonly stopWithQueue?: "thread.stop" | "run.interrupt"; |
| 51 | readonly olderStart?: boolean; | 53 | readonly olderStart?: boolean; |
| 54 | readonly stalledRun?: | ||
| 55 | | "missing-session" | ||
| 56 | | "missing-session-terminal" | ||
| 57 | | "returned-interrupt" | ||
| 58 | | "returned-interrupt-terminal" | ||
| 59 | | "superseded-attempt"; | ||
| 52 | }) => | 60 | }) => |
| 53 | Effect.scoped( | 61 | Effect.scoped( |
| 54 | Effect.gen(function* () { | 62 | Effect.gen(function* () { |
| ... | @@ -210,6 +218,181 @@ const stopEarlierBackgroundWork = ({ | ... | @@ -210,6 +218,181 @@ const stopEarlierBackgroundWork = ({ |
| 210 | }, | 218 | }, |
| 211 | ], | 219 | ], |
| 212 | }); | 220 | }); |
| 221 | if (stalledRun !== undefined) { | ||
| 222 | const before = yield* orchestrator.getThreadProjection(threadId); | ||
| 223 | const run = before.runs[0]!; | ||
| 224 | const node = before.nodes.find((candidate) => candidate.id === run.rootNodeId)!; | ||
| 225 | const attempt = before.attempts[0]!; | ||
| 226 | if (stalledRun === "missing-session" || stalledRun === "missing-session-terminal") { | ||
| 227 | const sessions = yield* ProviderSessionManager.ProviderSessionManagerV2; | ||
| 228 | const failed = yield* watch( | ||
| 229 | (event) => event.type === "run.updated" && event.payload.status === "failed", | ||
| 230 | ); | ||
| 231 | yield* sessions.release({ | ||
| 232 | providerSessionId: first.providerThread.providerSessionId!, | ||
| 233 | reason: "runtime_error", | ||
| 234 | }); | ||
| 235 | yield* Fiber.join(failed); | ||
| 236 | // Disk exhaustion can lose these terminal writes. Restore that stale state. | ||
| 237 | yield* sink.write({ | ||
| 238 | events: [ | ||
| 239 | { | ||
| 240 | id: EventId.make("stale-run"), | ||
| 241 | type: "run.updated", | ||
| 242 | threadId, | ||
| 243 | occurredAt: now, | ||
| 244 | payload: run, | ||
| 245 | }, | ||
| 246 | { | ||
| 247 | id: EventId.make("stale-node"), | ||
| 248 | type: "node.updated", | ||
| 249 | threadId, | ||
| 250 | occurredAt: now, | ||
| 251 | payload: node, | ||
| 252 | }, | ||
| 253 | { | ||
| 254 | id: EventId.make("stale-attempt"), | ||
| 255 | type: "run-attempt.updated", | ||
| 256 | threadId, | ||
| 257 | occurredAt: now, | ||
| 258 | payload: attempt, | ||
| 259 | }, | ||
| 260 | ], | ||
| 261 | }); | ||
| 262 | } | ||
| 263 | const messageId = MessageId.make("partial-output"); | ||
| 264 | const item = before.turnItems.find((candidate) => candidate.id === devServerId)!; | ||
| 265 | assert.ok(item.type === "command_execution"); | ||
| 266 | const terminalProviderTurn = stalledRun.endsWith("-terminal"); | ||
| 267 | if (terminalProviderTurn) { | ||
| 268 | yield* sink.write({ | ||
| 269 | events: [ | ||
| 270 | { | ||
| 271 | id: EventId.make("terminal-provider-turn"), | ||
| 272 | type: "provider-turn.updated", | ||
| 273 | threadId, | ||
| 274 | occurredAt: now, | ||
| 275 | payload: { ...codexTurn, status: "completed", completedAt: now }, | ||
| 276 | }, | ||
| 277 | ...(stalledRun === "missing-session-terminal" | ||
| 278 | ? [ | ||
| 279 | { | ||
| 280 | id: EventId.make("completed-dev-server"), | ||
| 281 | type: "turn-item.updated" as const, | ||
| 282 | threadId, | ||
| 283 | occurredAt: now, | ||
| 284 | payload: { ...item, status: "completed" as const, completedAt: now }, | ||
| 285 | }, | ||
| 286 | ] | ||
| 287 | : []), | ||
| 288 | ], | ||
| 289 | }); | ||
| 290 | } | ||
| 291 | yield* sink.write({ | ||
| 292 | events: [ | ||
| 293 | { | ||
| 294 | id: EventId.make("partial-message"), | ||
| 295 | type: "message.updated", | ||
| 296 | threadId, | ||
| 297 | runId: run.id, | ||
| 298 | occurredAt: now, | ||
| 299 | payload: { | ||
| 300 | id: messageId, | ||
| 301 | threadId, | ||
| 302 | runId: run.id, | ||
| 303 | nodeId: node.id, | ||
| 304 | role: "assistant", | ||
| 305 | text: "Partial output", | ||
| 306 | attachments: [], | ||
| 307 | streaming: true, | ||
| 308 | createdBy: "agent", | ||
| 309 | creationSource: "provider", | ||
| 310 | createdAt: now, | ||
| 311 | updatedAt: now, | ||
| 312 | }, | ||
| 313 | }, | ||
| 314 | { | ||
| 315 | id: EventId.make("partial-item"), | ||
| 316 | type: "turn-item.updated", | ||
| 317 | threadId, | ||
| 318 | runId: run.id, | ||
| 319 | occurredAt: now, | ||
| 320 | payload: { | ||
| 321 | ...item, | ||
| 322 | providerThreadId: codexTurn.providerThreadId, | ||
| 323 | providerTurnId: codexTurn.id, | ||
| 324 | id: TurnItemId.make("partial-output"), | ||
| 325 | type: "assistant_message", | ||
| 326 | messageId, | ||
| 327 | text: "Partial output", | ||
| 328 | streaming: true, | ||
| 329 | }, | ||
| 330 | }, | ||
| 331 | ], | ||
| 332 | }); | ||
| 333 | if (stalledRun === "superseded-attempt") { | ||
| 334 | yield* sink.write({ | ||
| 335 | events: [ | ||
| 336 | { | ||
| 337 | id: EventId.make("new-attempt"), | ||
| 338 | type: "run.updated", | ||
| 339 | threadId, | ||
| 340 | occurredAt: now, | ||
| 341 | payload: { ...run, activeAttemptId: RunAttemptId.make("new-attempt") }, | ||
| 342 | }, | ||
| 343 | ], | ||
| 344 | }); | ||
| 345 | } | ||
| 346 | yield* orchestrator.dispatch( | ||
| 347 | stalledRun === "superseded-attempt" | ||
| 348 | ? { | ||
| 349 | type: "thread.background-work.settle", | ||
| 350 | commandId: CommandId.make("late-settle"), | ||
| 351 | threadId, | ||
| 352 | providerThreadId: codexTurn.providerThreadId, | ||
| 353 | providerTurnId: codexTurn.id, | ||
| 354 | } | ||
| 355 | : { | ||
| 356 | type: "thread.stop", | ||
| 357 | commandId: CommandId.make("stop-stalled-run"), | ||
| 358 | threadId, | ||
| 359 | }, | ||
| 360 | ); | ||
| 361 | yield* worker.drain(); | ||
| 362 | const after = yield* orchestrator.getThreadProjection(threadId); | ||
| 363 | const interrupted = stalledRun !== "superseded-attempt"; | ||
| 364 | assert.equal(after.runs[0]?.status, interrupted ? "interrupted" : "running"); | ||
| 365 | assert.equal(after.attempts[0]?.status, interrupted ? "interrupted" : "running"); | ||
| 366 | assert.equal( | ||
| 367 | after.providerTurns[0]?.status, | ||
| 368 | terminalProviderTurn ? "completed" : interrupted ? "interrupted" : "running", | ||
| 369 | ); | ||
| 370 | assert.equal( | ||
| 371 | after.nodes.find((candidate) => candidate.id === node.id)?.status, | ||
| 372 | interrupted ? "interrupted" : "running", | ||
| 373 | ); | ||
| 374 | const output = after.messages.find((message) => message.id === messageId)!; | ||
| 375 | assert.equal(output.streaming, !interrupted); | ||
| 376 | assert.equal(output.text, "Partial output"); | ||
| 377 | assert.equal( | ||
| 378 | after.turnItems.find((candidate) => candidate.id === devServerId)?.status, | ||
| 379 | stalledRun === "missing-session-terminal" | ||
| 380 | ? "completed" | ||
| 381 | : interrupted | ||
| 382 | ? "interrupted" | ||
| 383 | : "running", | ||
| 384 | ); | ||
| 385 | assert.equal( | ||
| 386 | after.turnItems.find((candidate) => candidate.type === "assistant_message")?.status, | ||
| 387 | interrupted ? "interrupted" : "running", | ||
| 388 | ); | ||
| 389 | assert.equal( | ||
| 390 | after.turnItems.filter((candidate) => candidate.type === "run_interrupt_result").length, | ||
| 391 | interrupted ? 1 : 0, | ||
| 392 | ); | ||
| 393 | assert.isEmpty(after.runs.filter((candidate) => candidate.status === "waiting")); | ||
| 394 | return; | ||
| 395 | } | ||
| 213 | const settled = yield* watch( | 396 | const settled = yield* watch( |
| 214 | (event) => | 397 | (event) => |
| 215 | event.type === "run.updated" && | 398 | event.type === "run.updated" && |
| ... | @@ -599,3 +782,13 @@ it.effect.each(["thread.stop", "run.interrupt"] as const)( | ... | @@ -599,3 +782,13 @@ it.effect.each(["thread.stop", "run.interrupt"] as const)( |
| 599 | "%s reaches later background work when an older run is starting", | 782 | "%s reaches later background work when an older run is starting", |
| 600 | (stopType) => stopEarlierBackgroundWork({ stopWithQueue: stopType, olderStart: true }), | 783 | (stopType) => stopEarlierBackgroundWork({ stopWithQueue: stopType, olderStart: true }), |
| 601 | ); | 784 | ); |
| 785 | |||
| 786 | it.effect.each([ | ||
| 787 | "missing-session", | ||
| 788 | "missing-session-terminal", | ||
| 789 | "returned-interrupt", | ||
| 790 | "returned-interrupt-terminal", | ||
| 791 | "superseded-attempt", | ||
| 792 | ] as const)("Stop recovers a stalled run after %s without changing a newer attempt", (stalledRun) => | ||
| 793 | stopEarlierBackgroundWork({ stalledRun }), | ||
| 794 | ); |
apps/server/src/orchestration-v2/EffectWorker.test.ts+38-1| ... | @@ -81,13 +81,14 @@ function layerExecutorFor(input: { | ... | @@ -81,13 +81,14 @@ function layerExecutorFor(input: { |
| 81 | readonly failFirstStart?: Ref.Ref<boolean>; | 81 | readonly failFirstStart?: Ref.Ref<boolean>; |
| 82 | readonly threads?: Partial<ThreadManagementService.ThreadManagementService["Service"]>; | 82 | readonly threads?: Partial<ThreadManagementService.ThreadManagementService["Service"]>; |
| 83 | readonly continueAfterRestart?: boolean; | 83 | readonly continueAfterRestart?: boolean; |
| 84 | readonly interrupt?: ProviderTurnControlService.ProviderTurnControlServiceV2Shape["interrupt"]; | ||
| 84 | }) { | 85 | }) { |
| 85 | const record = (event: string) => Ref.update(input.events, (events) => [...events, event]); | 86 | const record = (event: string) => Ref.update(input.events, (events) => [...events, event]); |
| 86 | const layerDependencies = Layer.mergeAll( | 87 | const layerDependencies = Layer.mergeAll( |
| 87 | Layer.succeed( | 88 | Layer.succeed( |
| 88 | ProviderTurnControlService.ProviderTurnControlServiceV2, | 89 | ProviderTurnControlService.ProviderTurnControlServiceV2, |
| 89 | ProviderTurnControlService.ProviderTurnControlServiceV2.of({ | 90 | ProviderTurnControlService.ProviderTurnControlServiceV2.of({ |
| 90 | interrupt: () => Effect.void, | 91 | interrupt: input.interrupt ?? (() => Effect.void), |
| 91 | steer: () => Effect.void, | 92 | steer: () => Effect.void, |
| 92 | interruptAndAwaitTerminal: (request) => | 93 | interruptAndAwaitTerminal: (request) => |
| 93 | record( | 94 | record( |
| ... | @@ -193,6 +194,42 @@ it("does not retry pure interrupt races where the turn is already gone", () => { | ... | @@ -193,6 +194,42 @@ it("does not retry pure interrupt races where the turn is already gone", () => { |
| 193 | ); | 194 | ); |
| 194 | }); | 195 | }); |
| 195 | 196 | ||
| 197 | it.effect("settles a stopped run when its adapter has already lost the native turn", () => | ||
| 198 | Effect.gen(function* () { | ||
| 199 | const now = yield* DateTime.now; | ||
| 200 | const events = yield* Ref.make<ReadonlyArray<string>>([]); | ||
| 201 | const layer = layerExecutorFor({ | ||
| 202 | events, | ||
| 203 | interrupt: () => | ||
| 204 | new ProviderTurnControlService.ProviderTurnControlError({ | ||
| 205 | threadId, | ||
| 206 | operation: "interrupt", | ||
| 207 | providerTurnId, | ||
| 208 | cause: "Provider turn is not active.", | ||
| 209 | }), | ||
| 210 | threads: { | ||
| 211 | dispatch: (command) => | ||
| 212 | Ref.update(events, (current) => [...current, command.type]).pipe( | ||
| 213 | Effect.as({ sequence: 1, storedEvents: [] }), | ||
| 214 | ), | ||
| 215 | }, | ||
| 216 | }); | ||
| 217 | yield* Effect.gen(function* () { | ||
| 218 | const executor = yield* EffectWorker.OrchestrationEffectExecutorV2; | ||
| 219 | yield* executor.execute({ | ||
| 220 | ...restartEffect(now, { type: "detach" }), | ||
| 221 | request: { | ||
| 222 | type: "provider-turn.interrupt", | ||
| 223 | providerSessionId: oldSessionId, | ||
| 224 | providerThreadId, | ||
| 225 | providerTurnId, | ||
| 226 | }, | ||
| 227 | }); | ||
| 228 | }).pipe(Effect.provide(layer)); | ||
| 229 | assert.deepEqual(yield* Ref.get(events), ["thread.background-work.settle"]); | ||
| 230 | }), | ||
| 231 | ); | ||
| 232 | |||
| 196 | it.effect("requeues a claim when a pre-execution worker check fails", () => | 233 | it.effect("requeues a claim when a pre-execution worker check fails", () => |
| 197 | Effect.gen(function* () { | 234 | Effect.gen(function* () { |
| 198 | const now = DateTime.formatIso(yield* DateTime.now); | 235 | const now = DateTime.formatIso(yield* DateTime.now); |
apps/server/src/orchestration-v2/EffectWorker.ts+8| ... | @@ -171,6 +171,14 @@ export const layerExecutor: Layer.Layer< | ... | @@ -171,6 +171,14 @@ export const layerExecutor: Layer.Layer< |
| 171 | providerTurnId: effect.request.providerTurnId, | 171 | providerTurnId: effect.request.providerTurnId, |
| 172 | }) | 172 | }) |
| 173 | .pipe( | 173 | .pipe( |
| 174 | Effect.catch((cause) => | ||
| 175 | isNonRetryableProviderTurnControlFailure( | ||
| 176 | effect.request.type, | ||
| 177 | Cause.pretty(Cause.fail(cause)), | ||
| 178 | ) | ||
| 179 | ? Effect.void | ||
| 180 | : Effect.fail(cause), | ||
| 181 | ), | ||
| 174 | // The provider has stopped what it still ran and reported it. | 182 | // The provider has stopped what it still ran and reported it. |
| 175 | // Whatever the thread still shows on that provider thread is | 183 | // Whatever the thread still shows on that provider thread is |
| 176 | // work no process will report on, so the Stop ends it too. | 184 | // work no process will report on, so the Stop ends it too. |
apps/server/src/orchestration-v2/EventSink.ts+10-1| ... | @@ -90,6 +90,7 @@ export interface EventSinkV2Shape { | ... | @@ -90,6 +90,7 @@ export interface EventSinkV2Shape { |
| 90 | readonly activeAttemptId: RunAttemptId; | 90 | readonly activeAttemptId: RunAttemptId; |
| 91 | readonly expectedStatus: OrchestrationV2Run["status"]; | 91 | readonly expectedStatus: OrchestrationV2Run["status"]; |
| 92 | readonly events: ReadonlyArray<OrchestrationV2DomainEvent>; | 92 | readonly events: ReadonlyArray<OrchestrationV2DomainEvent>; |
| 93 | readonly effects?: ReadonlyArray<EffectOutbox.PendingOrchestrationEffectV2>; | ||
| 93 | }) => Effect.Effect< | 94 | }) => Effect.Effect< |
| 94 | { | 95 | { |
| 95 | readonly committed: boolean; | 96 | readonly committed: boolean; |
| ... | @@ -437,9 +438,17 @@ const layerBase: Layer.Layer< | ... | @@ -437,9 +438,17 @@ const layerBase: Layer.Layer< |
| 437 | events: normalized, | 438 | events: normalized, |
| 438 | }); | 439 | }); |
| 439 | yield* applyStoredEvents(storedEvents); | 440 | yield* applyStoredEvents(storedEvents); |
| 441 | yield* effectOutbox.enqueue(input.effects ?? []); | ||
| 440 | return { committed: true as const, storedEvents }; | 442 | return { committed: true as const, storedEvents }; |
| 441 | }), | 443 | }), |
| 442 | (result) => (result.committed ? publishStoredEvents(result.storedEvents) : Effect.void), | 444 | (result) => |
| 445 | Effect.gen(function* () { | ||
| 446 | if (!result.committed) return; | ||
| 447 | if (input.effects !== undefined && input.effects.length > 0) { | ||
| 448 | yield* effectOutbox.notifyAvailable(input.effects.length); | ||
| 449 | } | ||
| 450 | yield* publishStoredEvents(result.storedEvents); | ||
| 451 | }), | ||
| 443 | ); | 452 | ); |
| 444 | }, | 453 | }, |
| 445 | ); | 454 | ); |
apps/server/src/orchestration-v2/FoundationPersistence.test.ts+28-1| ... | @@ -1525,6 +1525,7 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => { | ... | @@ -1525,6 +1525,7 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => { |
| 1525 | Effect.gen(function* () { | 1525 | Effect.gen(function* () { |
| 1526 | const eventSink = yield* EventSink.EventSinkV2; | 1526 | const eventSink = yield* EventSink.EventSinkV2; |
| 1527 | const projectionStore = yield* ProjectionStore.ProjectionStoreV2; | 1527 | const projectionStore = yield* ProjectionStore.ProjectionStoreV2; |
| 1528 | const outbox = yield* EffectOutbox.EffectOutboxV2; | ||
| 1528 | const now = yield* DateTime.now; | 1529 | const now = yield* DateTime.now; |
| 1529 | const threadId = ThreadId.make("thread:foundation-stale-provider-start"); | 1530 | const threadId = ThreadId.make("thread:foundation-stale-provider-start"); |
| 1530 | const runId = RunId.make("run:foundation-stale-provider-start"); | 1531 | const runId = RunId.make("run:foundation-stale-provider-start"); |
| ... | @@ -1567,6 +1568,30 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => { | ... | @@ -1567,6 +1568,30 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => { |
| 1567 | ], | 1568 | ], |
| 1568 | }); | 1569 | }); |
| 1569 | 1570 | ||
| 1571 | const captureEffect = { | ||
| 1572 | id: "effect:foundation-current-capture", | ||
| 1573 | commandId: CommandId.make("command:foundation-current-capture"), | ||
| 1574 | threadId, | ||
| 1575 | request: { | ||
| 1576 | type: "checkpoint.capture" as const, | ||
| 1577 | runId, | ||
| 1578 | scopeId: CheckpointScopeId.make("scope:foundation-current-capture"), | ||
| 1579 | }, | ||
| 1580 | }; | ||
| 1581 | assert.isTrue( | ||
| 1582 | (yield* eventSink.writeIfRunCurrent({ | ||
| 1583 | threadId, | ||
| 1584 | runId, | ||
| 1585 | activeAttemptId: attemptId, | ||
| 1586 | expectedStatus: "starting", | ||
| 1587 | events: [], | ||
| 1588 | effects: [captureEffect], | ||
| 1589 | })).committed, | ||
| 1590 | ); | ||
| 1591 | assert.isTrue(Option.isSome(yield* outbox.get(captureEffect.id))); | ||
| 1592 | yield* outbox.awaitAvailable; | ||
| 1593 | const staleCaptureEffect = { ...captureEffect, id: "effect:foundation-stale-capture" }; | ||
| 1594 | |||
| 1570 | const reachedPrecommitGap = yield* Deferred.make<void>(); | 1595 | const reachedPrecommitGap = yield* Deferred.make<void>(); |
| 1571 | const releaseStaleStart = yield* Deferred.make<void>(); | 1596 | const releaseStaleStart = yield* Deferred.make<void>(); |
| 1572 | const providerStartCount = yield* Ref.make(0); | 1597 | const providerStartCount = yield* Ref.make(0); |
| ... | @@ -1578,6 +1603,7 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => { | ... | @@ -1578,6 +1603,7 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => { |
| 1578 | runId, | 1603 | runId, |
| 1579 | activeAttemptId: attemptId, | 1604 | activeAttemptId: attemptId, |
| 1580 | expectedStatus: "starting", | 1605 | expectedStatus: "starting", |
| 1606 | effects: [staleCaptureEffect], | ||
| 1581 | events: [ | 1607 | events: [ |
| 1582 | { | 1608 | { |
| 1583 | id: EventId.make("event:foundation-stale-provider-start:running"), | 1609 | id: EventId.make("event:foundation-stale-provider-start:running"), |
| ... | @@ -1619,11 +1645,12 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => { | ... | @@ -1619,11 +1645,12 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => { |
| 1619 | 1645 | ||
| 1620 | const staleResult = yield* Fiber.join(staleStartFiber); | 1646 | const staleResult = yield* Fiber.join(staleStartFiber); |
| 1621 | assert.isFalse(staleResult.committed); | 1647 | assert.isFalse(staleResult.committed); |
| 1648 | assert.isTrue(Option.isNone(yield* outbox.get(staleCaptureEffect.id))); | ||
| 1622 | assert.deepEqual(staleResult.storedEvents, []); | 1649 | assert.deepEqual(staleResult.storedEvents, []); |
| 1623 | assert.equal(yield* Ref.get(providerStartCount), 0); | 1650 | assert.equal(yield* Ref.get(providerStartCount), 0); |
| 1624 | const projection = yield* projectionStore.getThreadProjection(threadId); | 1651 | const projection = yield* projectionStore.getThreadProjection(threadId); |
| 1625 | assert.equal(projection.runs[0]?.status, "cancelled"); | 1652 | assert.equal(projection.runs[0]?.status, "cancelled"); |
| 1626 | }), | 1653 | }).pipe(Effect.provide(Layer.fresh(layerTest))), |
| 1627 | ); | 1654 | ); |
| 1628 | 1655 | ||
| 1629 | it.effect("guards post-terminal provider-thread writes by attempt and run ordinal", () => | 1656 | it.effect("guards post-terminal provider-thread writes by attempt and run ordinal", () => |
apps/server/src/orchestration-v2/Orchestrator.ts+267-38| ... | @@ -102,6 +102,7 @@ import type { ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; | ... | @@ -102,6 +102,7 @@ import type { ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; |
| 102 | import { ProviderAdapterRegistryV2 } from "./ProviderAdapterRegistry.ts"; | 102 | import { ProviderAdapterRegistryV2 } from "./ProviderAdapterRegistry.ts"; |
| 103 | import { ProviderContinuationRequests } from "./ProviderContinuationRequests.ts"; | 103 | import { ProviderContinuationRequests } from "./ProviderContinuationRequests.ts"; |
| 104 | import { makeProviderFailure } from "./ProviderFailure.ts"; | 104 | import { makeProviderFailure } from "./ProviderFailure.ts"; |
| 105 | import * as RunExecutionService from "./RunExecutionService.ts"; | ||
| 105 | import { ProviderSessionManagerV2 } from "./ProviderSessionManager.ts"; | 106 | import { ProviderSessionManagerV2 } from "./ProviderSessionManager.ts"; |
| 106 | import { ProviderSwitchServiceV2 } from "./ProviderSwitchService.ts"; | 107 | import { ProviderSwitchServiceV2 } from "./ProviderSwitchService.ts"; |
| 107 | import { isAutomaticCompletionRun, queuedRunsInDeliveryOrder } from "./QueuedRunOrder.ts"; | 108 | import { isAutomaticCompletionRun, queuedRunsInDeliveryOrder } from "./QueuedRunOrder.ts"; |
| ... | @@ -8116,9 +8117,204 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | ... | @@ -8116,9 +8117,204 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 8116 | driver: providerThread.driver, | 8117 | driver: providerThread.driver, |
| 8117 | providerInstanceId: providerThread.providerInstanceId, | 8118 | providerInstanceId: providerThread.providerInstanceId, |
| 8118 | occurredAt: input.now, | 8119 | occurredAt: input.now, |
| 8119 | payload: { ...providerThread, pendingBackgroundTasks: [], updatedAt: input.now }, | 8120 | payload: { |
| 8121 | ...providerThread, | ||
| 8122 | status: | ||
| 8123 | providerThread.status === "active" && | ||
| 8124 | providerThread.lastRunOrdinal !== null && | ||
| 8125 | providerThread.lastRunOrdinal <= input.throughRunOrdinal | ||
| 8126 | ? "idle" | ||
| 8127 | : providerThread.status, | ||
| 8128 | pendingBackgroundTasks: [], | ||
| 8129 | updatedAt: input.now, | ||
| 8130 | }, | ||
| 8131 | }); | ||
| 8132 | } | ||
| 8133 | }); | ||
| 8134 | |||
| 8135 | // A successful Stop may have no terminal event to ingest: the session or | ||
| 8136 | // native turn already died, or its final write failed when storage filled. | ||
| 8137 | const settleInterruptedRun = (input: { | ||
| 8138 | readonly command: Extract< | ||
| 8139 | OrchestrationV2ServerCommand, | ||
| 8140 | { readonly type: "run.interrupt" | "thread.background-work.settle" } | ||
| 8141 | >; | ||
| 8142 | readonly projection: Pick< | ||
| 8143 | OrchestrationV2ThreadProjection, | ||
| 8144 | | "runs" | ||
| 8145 | | "attempts" | ||
| 8146 | | "nodes" | ||
| 8147 | | "subagents" | ||
| 8148 | | "runtimeRequests" | ||
| 8149 | | "messages" | ||
| 8150 | | "turnItems" | ||
| 8151 | | "providerThreads" | ||
| 8152 | >; | ||
| 8153 | readonly providerTurn: OrchestrationV2ProviderTurn; | ||
| 8154 | readonly events: Ref.Ref<Array<OrchestrationV2DomainEvent>>; | ||
| 8155 | readonly effects: Ref.Ref<Array<PendingOrchestrationEffectV2>>; | ||
| 8156 | readonly now: DateTime.Utc; | ||
| 8157 | }) => | ||
| 8158 | Effect.gen(function* () { | ||
| 8159 | const attempt = input.projection.attempts.find( | ||
| 8160 | (candidate) => candidate.id === input.providerTurn.runAttemptId, | ||
| 8161 | ); | ||
| 8162 | const run = input.projection.runs.find((candidate) => candidate.id === attempt?.runId); | ||
| 8163 | const rootNode = input.projection.nodes.find((candidate) => candidate.id === run?.rootNodeId); | ||
| 8164 | if ( | ||
| 8165 | attempt === undefined || | ||
| 8166 | run === undefined || | ||
| 8167 | rootNode === undefined || | ||
| 8168 | run.activeAttemptId !== attempt.id || | ||
| 8169 | run.status !== "running" || | ||
| 8170 | attempt.status !== "running" | ||
| 8171 | ) | ||
| 8172 | return; | ||
| 8173 | const emitEvent = emit(input.events, input.command); | ||
| 8174 | const base = { | ||
| 8175 | threadId: input.command.threadId, | ||
| 8176 | runId: run.id, | ||
| 8177 | nodeId: rootNode.id, | ||
| 8178 | providerInstanceId: run.providerInstanceId, | ||
| 8179 | occurredAt: input.now, | ||
| 8180 | }; | ||
| 8181 | if (input.providerTurn.status === "running") { | ||
| 8182 | yield* emitEvent({ | ||
| 8183 | ...base, | ||
| 8184 | type: "provider-turn.updated", | ||
| 8185 | payload: { | ||
| 8186 | ...input.providerTurn, | ||
| 8187 | status: "interrupted", | ||
| 8188 | completedAt: input.now, | ||
| 8189 | }, | ||
| 8190 | }); | ||
| 8191 | } | ||
| 8192 | yield* emitEvent({ | ||
| 8193 | ...base, | ||
| 8194 | type: "run-attempt.updated", | ||
| 8195 | payload: { | ||
| 8196 | ...attempt, | ||
| 8197 | status: "interrupted", | ||
| 8198 | completedAt: input.now, | ||
| 8199 | }, | ||
| 8200 | }); | ||
| 8201 | const providerThread = input.projection.providerThreads.find( | ||
| 8202 | (candidate) => candidate.id === input.providerTurn.providerThreadId, | ||
| 8203 | ); | ||
| 8204 | if (providerThread?.status === "active" && providerThread.lastRunOrdinal === run.ordinal) { | ||
| 8205 | yield* emitEvent({ | ||
| 8206 | ...base, | ||
| 8207 | type: "provider-thread.updated", | ||
| 8208 | payload: { ...providerThread, status: "idle", updatedAt: input.now }, | ||
| 8209 | }); | ||
| 8210 | } | ||
| 8211 | for (const message of input.projection.messages) { | ||
| 8212 | if (message.runId !== run.id || !message.streaming) continue; | ||
| 8213 | yield* emitEvent({ | ||
| 8214 | ...base, | ||
| 8215 | ...(message.nodeId === null ? {} : { nodeId: message.nodeId }), | ||
| 8216 | type: "message.updated", | ||
| 8217 | payload: { ...message, streaming: false, updatedAt: input.now }, | ||
| 8218 | }); | ||
| 8219 | } | ||
| 8220 | const delegatedNodeIds = new Set( | ||
| 8221 | input.projection.subagents | ||
| 8222 | .filter((subagent) => subagent.origin === "app_owned") | ||
| 8223 | .map((subagent) => subagent.id), | ||
| 8224 | ); | ||
| 8225 | const cascaded = yield* RunExecutionService.cascadeTerminalizeRunOwnedSubagents({ | ||
| 8226 | run, | ||
| 8227 | status: "interrupted", | ||
| 8228 | completedAt: input.now, | ||
| 8229 | allocateEventId: () => | ||
| 8230 | idAllocator.allocate.event({ | ||
| 8231 | threadId: input.command.threadId, | ||
| 8232 | commandId: input.command.commandId, | ||
| 8233 | }), | ||
| 8234 | open: { | ||
| 8235 | subagents: new Map( | ||
| 8236 | input.projection.subagents | ||
| 8237 | .filter((task) => task.runId === run.id && task.origin !== "app_owned") | ||
| 8238 | .map((task) => [task.id, task]), | ||
| 8239 | ), | ||
| 8240 | nodes: new Map( | ||
| 8241 | input.projection.nodes | ||
| 8242 | .filter((node) => node.runId === run.id && !delegatedNodeIds.has(node.id)) | ||
| 8243 | .map((node) => [node.id, node]), | ||
| 8244 | ), | ||
| 8245 | turnItems: new Map(), | ||
| 8246 | childTurnItems: new Map( | ||
| 8247 | input.projection.turnItems | ||
| 8248 | .filter( | ||
| 8249 | (item) => | ||
| 8250 | item.runId === run.id && | ||
| 8251 | (item.type === "assistant_message" || item.type === "reasoning"), | ||
| 8252 | ) | ||
| 8253 | .map((item) => [item.id, item]), | ||
| 8254 | ), | ||
| 8255 | linkedChildThreadIds: new Set([run.threadId]), | ||
| 8256 | }, | ||
| 8257 | }).pipe(mapDispatchError(input.command)); | ||
| 8258 | yield* Ref.update(input.events, (events) => [...events, ...cascaded]); | ||
| 8259 | for (const request of input.projection.runtimeRequests) { | ||
| 8260 | if ( | ||
| 8261 | request.status !== "pending" || | ||
| 8262 | !input.projection.nodes.some( | ||
| 8263 | (node) => node.id === request.nodeId && node.runId === run.id, | ||
| 8264 | ) | ||
| 8265 | ) | ||
| 8266 | continue; | ||
| 8267 | yield* emitEvent({ | ||
| 8268 | ...base, | ||
| 8269 | nodeId: request.nodeId, | ||
| 8270 | type: "runtime-request.updated", | ||
| 8271 | payload: { | ||
| 8272 | ...request, | ||
| 8273 | status: "cancelled", | ||
| 8274 | resolvedAt: input.now, | ||
| 8275 | responseCapability: { type: "not_resumable", reason: "The run was interrupted." }, | ||
| 8276 | }, | ||
| 8277 | }); | ||
| 8278 | } | ||
| 8279 | if (providerThread !== undefined) { | ||
| 8280 | yield* emitEvent({ | ||
| 8281 | ...base, | ||
| 8282 | type: "turn-item.updated", | ||
| 8283 | payload: RunExecutionService.makeInterruptResultTurnItem({ | ||
| 8284 | idAllocator, | ||
| 8285 | run, | ||
| 8286 | rootNode, | ||
| 8287 | providerThread, | ||
| 8288 | completedAt: input.now, | ||
| 8289 | }), | ||
| 8120 | }); | 8290 | }); |
| 8121 | } | 8291 | } |
| 8292 | const { delegatedCompletion: _delegatedCompletion, ...runWithoutDelegatedCompletion } = run; | ||
| 8293 | yield* emitEvent({ | ||
| 8294 | ...base, | ||
| 8295 | type: "run.updated", | ||
| 8296 | payload: { | ||
| 8297 | ...runWithoutDelegatedCompletion, | ||
| 8298 | status: "interrupted", | ||
| 8299 | completedAt: input.now, | ||
| 8300 | }, | ||
| 8301 | }); | ||
| 8302 | const scopeId = rootNode.checkpointScopeId; | ||
| 8303 | if (scopeId !== null) { | ||
| 8304 | yield* Ref.update(input.effects, (effects) => [ | ||
| 8305 | ...effects, | ||
| 8306 | { | ||
| 8307 | id: `effect:checkpoint.capture:${run.id}`, | ||
| 8308 | commandId: CommandId.make(`command:effect:checkpoint.capture:${run.id}`), | ||
| 8309 | threadId: input.command.threadId, | ||
| 8310 | request: { | ||
| 8311 | type: "checkpoint.capture", | ||
| 8312 | runId: run.id, | ||
| 8313 | scopeId, | ||
| 8314 | }, | ||
| 8315 | } satisfies PendingOrchestrationEffectV2, | ||
| 8316 | ]); | ||
| 8317 | } | ||
| 8122 | }); | 8318 | }); |
| 8123 | 8319 | ||
| 8124 | /** | 8320 | /** |
| ... | @@ -8280,14 +8476,29 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | ... | @@ -8280,14 +8476,29 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 8280 | { readonly type: "thread.background-work.settle" } | 8476 | { readonly type: "thread.background-work.settle" } |
| 8281 | >, | 8477 | >, |
| 8282 | events: Ref.Ref<Array<OrchestrationV2DomainEvent>>, | 8478 | events: Ref.Ref<Array<OrchestrationV2DomainEvent>>, |
| 8479 | effects: Ref.Ref<Array<PendingOrchestrationEffectV2>>, | ||
| 8283 | ) => | 8480 | ) => |
| 8284 | Effect.gen(function* () { | 8481 | Effect.gen(function* () { |
| 8285 | const [projection, stopped] = yield* Effect.all([ | 8482 | const [projection, stopped] = yield* Effect.all([ |
| 8286 | projectionStore.getThreadRecords( | 8483 | projectionStore.getThreadRecords( |
| 8287 | command.threadId, | 8484 | command.threadId, |
| 8288 | ["runs", "attempts", "turnItems", "providerThreads"], | 8485 | [ |
| 8486 | "runs", | ||
| 8487 | "attempts", | ||
| 8488 | "nodes", | ||
| 8489 | "subagents", | ||
| 8490 | "runtimeRequests", | ||
| 8491 | "turnItems", | ||
| 8492 | "providerThreads", | ||
| 8493 | ], | ||
| 8289 | { | 8494 | { |
| 8290 | turnItemTypes: ["command_execution", "dynamic_tool", "subagent"], | 8495 | turnItemTypes: [ |
| 8496 | "command_execution", | ||
| 8497 | "dynamic_tool", | ||
| 8498 | "subagent", | ||
| 8499 | "assistant_message", | ||
| 8500 | "reasoning", | ||
| 8501 | ], | ||
| 8291 | turnItemStatuses: ["pending", "running", "waiting"], | 8502 | turnItemStatuses: ["pending", "running", "waiting"], |
| 8292 | }, | 8503 | }, |
| 8293 | ), | 8504 | ), |
| ... | @@ -8304,12 +8515,35 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | ... | @@ -8304,12 +8515,35 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 8304 | (attempt) => attempt.id === stopped.providerTurn?.runAttemptId, | 8515 | (attempt) => attempt.id === stopped.providerTurn?.runAttemptId, |
| 8305 | )?.runId; | 8516 | )?.runId; |
| 8306 | const stoppedRun = projection.runs.find((run) => run.id === stoppedRunId); | 8517 | const stoppedRun = projection.runs.find((run) => run.id === stoppedRunId); |
| 8307 | // A new turn may have started since Stop; its work is not this Stop's. | ||
| 8308 | if ( | 8518 | if ( |
| 8309 | stoppedRun === undefined || | 8519 | stoppedRun === undefined || |
| 8520 | (["preparing", "starting", "running"].includes(stoppedRun.status) && | ||
| 8521 | stoppedRun.activeAttemptId !== stopped.providerTurn?.runAttemptId) | ||
| 8522 | ) | ||
| 8523 | return; | ||
| 8524 | const now = yield* DateTime.now; | ||
| 8525 | if (stopped.providerTurn !== undefined && stoppedRun.status === "running") { | ||
| 8526 | const output = yield* projectionStore | ||
| 8527 | .getThreadRecords(command.threadId, ["messages"], { | ||
| 8528 | messageRunIds: [stoppedRun.id], | ||
| 8529 | messageRoles: ["assistant"], | ||
| 8530 | }) | ||
| 8531 | .pipe(mapDispatchError(command)); | ||
| 8532 | yield* settleInterruptedRun({ | ||
| 8533 | command, | ||
| 8534 | projection: { ...projection, messages: output.messages }, | ||
| 8535 | providerTurn: stopped.providerTurn, | ||
| 8536 | events, | ||
| 8537 | effects, | ||
| 8538 | now, | ||
| 8539 | }); | ||
| 8540 | } | ||
| 8541 | // A new turn may have started since Stop; its work is not this Stop's. | ||
| 8542 | if ( | ||
| 8310 | projection.runs.some( | 8543 | projection.runs.some( |
| 8311 | (run) => | 8544 | (run) => |
| 8312 | run.status === "preparing" || run.status === "starting" || run.status === "running", | 8545 | run.id !== stoppedRun.id && |
| 8546 | (run.status === "preparing" || run.status === "starting" || run.status === "running"), | ||
| 8313 | ) | 8547 | ) |
| 8314 | ) { | 8548 | ) { |
| 8315 | return; | 8549 | return; |
| ... | @@ -8320,7 +8554,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | ... | @@ -8320,7 +8554,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 8320 | projection, | 8554 | projection, |
| 8321 | stoppedProviderThreadId: command.providerThreadId, | 8555 | stoppedProviderThreadId: command.providerThreadId, |
| 8322 | throughRunOrdinal: stoppedRun.ordinal, | 8556 | throughRunOrdinal: stoppedRun.ordinal, |
| 8323 | now: yield* DateTime.now, | 8557 | now, |
| 8324 | }); | 8558 | }); |
| 8325 | }); | 8559 | }); |
| 8326 | 8560 | ||
| ... | @@ -8341,11 +8575,18 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | ... | @@ -8341,11 +8575,18 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 8341 | "attempts", | 8575 | "attempts", |
| 8342 | "turnItems", | 8576 | "turnItems", |
| 8343 | "subagents", | 8577 | "subagents", |
| 8578 | "runtimeRequests", | ||
| 8344 | ], | 8579 | ], |
| 8345 | // Thread-wide, like the Waiting strip: a settled thread's background | 8580 | // Thread-wide, like the Waiting strip: a settled thread's background |
| 8346 | // work can belong to an earlier run than the one Stop targets. | 8581 | // work can belong to an earlier run than the one Stop targets. |
| 8347 | { | 8582 | { |
| 8348 | turnItemTypes: ["command_execution", "dynamic_tool", "subagent"], | 8583 | turnItemTypes: [ |
| 8584 | "command_execution", | ||
| 8585 | "dynamic_tool", | ||
| 8586 | "subagent", | ||
| 8587 | "assistant_message", | ||
| 8588 | "reasoning", | ||
| 8589 | ], | ||
| 8349 | turnItemStatuses: ["pending", "running", "waiting"], | 8590 | turnItemStatuses: ["pending", "running", "waiting"], |
| 8350 | }, | 8591 | }, |
| 8351 | ); | 8592 | ); |
| ... | @@ -8373,7 +8614,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | ... | @@ -8373,7 +8614,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 8373 | projection.providerTurns.findLast( | 8614 | projection.providerTurns.findLast( |
| 8374 | (candidate) => | 8615 | (candidate) => |
| 8375 | candidate.runAttemptId === run?.activeAttemptId && | 8616 | candidate.runAttemptId === run?.activeAttemptId && |
| 8376 | (candidate.status === "running" || hasBackgroundWork), | 8617 | (run?.status === "running" || candidate.status === "running" || hasBackgroundWork), |
| 8377 | ) ?? | 8618 | ) ?? |
| 8378 | (hasBackgroundWork | 8619 | (hasBackgroundWork |
| 8379 | ? projection.providerTurns.findLast( | 8620 | ? projection.providerTurns.findLast( |
| ... | @@ -8623,18 +8864,22 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | ... | @@ -8623,18 +8864,22 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 8623 | cause: `Run ${command.runId} is not interruptible.`, | 8864 | cause: `Run ${command.runId} is not interruptible.`, |
| 8624 | }); | 8865 | }); |
| 8625 | } | 8866 | } |
| 8626 | // Stop on a settled thread's background work. Its process may be gone | 8867 | // A dead session cannot report a terminal event for either a running |
| 8627 | // (released, restarted) and only the projection still shows the work; | 8868 | // turn or a settled turn's background work. Stop ends both locally. |
| 8628 | // the settle follow-up ends whatever no provider reports ending. | 8869 | const sessionOption = |
| 8629 | const settleOnly = | 8870 | providerThread.providerSessionId === null |
| 8630 | providerTurn.status !== "running" && | 8871 | ? Option.none() |
| 8631 | (providerThread.providerSessionId === null || | 8872 | : yield* providerSessions.get(providerThread.providerSessionId).pipe( |
| 8632 | Option.isNone( | 8873 | Effect.mapError( |
| 8633 | yield* providerSessions | 8874 | (cause) => |
| 8634 | .get(providerThread.providerSessionId) | 8875 | new OrchestratorProviderAdapterError({ |
| 8635 | .pipe(Effect.orElseSucceed(() => Option.none())), | 8876 | commandId: command.commandId, |
| 8636 | )); | 8877 | providerInstanceId: run.providerInstanceId, |
| 8637 | if (settleOnly) { | 8878 | cause, |
| 8879 | }), | ||
| 8880 | ), | ||
| 8881 | ); | ||
| 8882 | if (Option.isNone(sessionOption)) { | ||
| 8638 | yield* emitEvent({ | 8883 | yield* emitEvent({ |
| 8639 | type: "turn-item.updated", | 8884 | type: "turn-item.updated", |
| 8640 | threadId: command.threadId, | 8885 | threadId: command.threadId, |
| ... | @@ -8645,6 +8890,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | ... | @@ -8645,6 +8890,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 8645 | payload: interruptRequestItem, | 8890 | payload: interruptRequestItem, |
| 8646 | }); | 8891 | }); |
| 8647 | yield* stopRemainingWork(); | 8892 | yield* stopRemainingWork(); |
| 8893 | yield* settleInterruptedRun({ command, projection, providerTurn, events, effects, now }); | ||
| 8648 | yield* settleBackgroundWork({ | 8894 | yield* settleBackgroundWork({ |
| 8649 | command, | 8895 | command, |
| 8650 | events, | 8896 | events, |
| ... | @@ -8664,23 +8910,6 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | ... | @@ -8664,23 +8910,6 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 8664 | }); | 8910 | }); |
| 8665 | } | 8911 | } |
| 8666 | const providerSessionId = providerThread.providerSessionId; | 8912 | const providerSessionId = providerThread.providerSessionId; |
| 8667 | const sessionOption = yield* providerSessions.get(providerSessionId).pipe( | ||
| 8668 | Effect.mapError( | ||
| 8669 | (cause) => | ||
| 8670 | new OrchestratorProviderAdapterError({ | ||
| 8671 | commandId: command.commandId, | ||
| 8672 | providerInstanceId: run.providerInstanceId, | ||
| 8673 | cause, | ||
| 8674 | }), | ||
| 8675 | ), | ||
| 8676 | ); | ||
| 8677 | if (Option.isNone(sessionOption)) { | ||
| 8678 | return yield* new OrchestratorDispatchError({ | ||
| 8679 | commandId: command.commandId, | ||
| 8680 | commandType: command.type, | ||
| 8681 | cause: `Provider session ${providerThread.providerSessionId} is not active.`, | ||
| 8682 | }); | ||
| 8683 | } | ||
| 8684 | yield* enforceCommandPolicy(command)( | 8913 | yield* enforceCommandPolicy(command)( |
| 8685 | commandPolicy.ensureInterrupt({ | 8914 | commandPolicy.ensureInterrupt({ |
| 8686 | commandId: command.commandId, | 8915 | commandId: command.commandId, |
| ... | @@ -10079,7 +10308,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | ... | @@ -10079,7 +10308,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 10079 | yield* dispatchCheckpointRollbackFail(command, events); | 10308 | yield* dispatchCheckpointRollbackFail(command, events); |
| 10080 | break; | 10309 | break; |
| 10081 | case "thread.background-work.settle": | 10310 | case "thread.background-work.settle": |
| 10082 | yield* dispatchBackgroundWorkSettle(command, events); | 10311 | yield* dispatchBackgroundWorkSettle(command, events, effects); |
| 10083 | break; | 10312 | break; |
| 10084 | case "thread.fork": | 10313 | case "thread.fork": |
| 10085 | yield* dispatchThreadFork(command, events); | 10314 | yield* dispatchThreadFork(command, events); |
apps/server/src/orchestration-v2/ProviderTurnControlService.ts+14| ... | @@ -12,6 +12,7 @@ import * as Effect from "effect/Effect"; | ... | @@ -12,6 +12,7 @@ import * as Effect from "effect/Effect"; |
| 12 | import * as Layer from "effect/Layer"; | 12 | import * as Layer from "effect/Layer"; |
| 13 | import * as Option from "effect/Option"; | 13 | import * as Option from "effect/Option"; |
| 14 | import * as Schema from "effect/Schema"; | 14 | import * as Schema from "effect/Schema"; |
| 15 | import * as NodeTimersPromises from "node:timers/promises"; | ||
| 15 | 16 | ||
| 16 | import * as ProjectionStore from "./ProjectionStore.ts"; | 17 | import * as ProjectionStore from "./ProjectionStore.ts"; |
| 17 | import * as ProviderSessionManager from "./ProviderSessionManager.ts"; | 18 | import * as ProviderSessionManager from "./ProviderSessionManager.ts"; |
| ... | @@ -185,6 +186,19 @@ export const layer: Layer.Layer< | ... | @@ -185,6 +186,19 @@ export const layer: Layer.Layer< |
| 185 | providerTurnId: loaded.providerTurn.id, | 186 | providerTurnId: loaded.providerTurn.id, |
| 186 | requestRuntimeRestart: true, | 187 | requestRuntimeRestart: true, |
| 187 | }); | 188 | }); |
| 189 | // Give native terminal ingestion time to finish before the Stop | ||
| 190 | // follow-up repairs a run whose provider no longer reports on it. | ||
| 191 | const deadline = performance.now() + 2_000; | ||
| 192 | while (loaded.providerTurn.status === "running" && performance.now() < deadline) { | ||
| 193 | const current = yield* projections.getProviderControlContext(input.threadId, input); | ||
| 194 | if ( | ||
| 195 | current.providerTurn?.status !== "running" && | ||
| 196 | current.attempt?.status !== "running" | ||
| 197 | ) { | ||
| 198 | break; | ||
| 199 | } | ||
| 200 | yield* Effect.promise(() => NodeTimersPromises.setTimeout(10)); | ||
| 201 | } | ||
| 188 | }).pipe( | 202 | }).pipe( |
| 189 | Effect.mapError((cause) => | 203 | Effect.mapError((cause) => |
| 190 | isProviderTurnControlError(cause) | 204 | isProviderTurnControlError(cause) |
apps/server/src/orchestration-v2/RunExecutionService.test.ts+49-6| ... | @@ -3243,9 +3243,27 @@ it.effect("omits run_interrupt_result when superseded attempt request is already | ... | @@ -3243,9 +3243,27 @@ it.effect("omits run_interrupt_result when superseded attempt request is already |
| 3243 | }), | 3243 | }), |
| 3244 | ); | 3244 | ); |
| 3245 | 3245 | ||
| 3246 | it.effect("does not overwrite Stop when ownership changes after the finalization read", () => | ||
| 3247 | Effect.gen(function* () { | ||
| 3248 | const { written, observed, submittedEffects, committedEffects } = | ||
| 3249 | yield* captureRootRunTermination({ | ||
| 3250 | key: "stop-wins-finalization-gap", | ||
| 3251 | shouldFinalizeRun: () => Effect.succeed(true), | ||
| 3252 | rejectTerminalWrite: true, | ||
| 3253 | }); | ||
| 3254 | assert.deepEqual(written, []); | ||
| 3255 | assert.deepEqual(observed, []); | ||
| 3256 | assert.deepEqual( | ||
| 3257 | submittedEffects.map((effect) => effect.request.type), | ||
| 3258 | ["checkpoint.capture"], | ||
| 3259 | ); | ||
| 3260 | assert.deepEqual(committedEffects, []); | ||
| 3261 | }), | ||
| 3262 | ); | ||
| 3263 | |||
| 3246 | it.effect("emits run_interrupt_result when hard-stop finalizes the active attempt", () => | 3264 | it.effect("emits run_interrupt_result when hard-stop finalizes the active attempt", () => |
| 3247 | Effect.gen(function* () { | 3265 | Effect.gen(function* () { |
| 3248 | const { written, observed } = yield* captureRootRunTermination({ | 3266 | const { written, observed, committedEffects } = yield* captureRootRunTermination({ |
| 3249 | key: "hard-stop", | 3267 | key: "hard-stop", |
| 3250 | shouldFinalizeRun: () => Effect.succeed(true), | 3268 | shouldFinalizeRun: () => Effect.succeed(true), |
| 3251 | }); | 3269 | }); |
| ... | @@ -3254,6 +3272,10 @@ it.effect("emits run_interrupt_result when hard-stop finalizes the active attemp | ... | @@ -3254,6 +3272,10 @@ it.effect("emits run_interrupt_result when hard-stop finalizes the active attemp |
| 3254 | ["run_interrupt_result"], | 3272 | ["run_interrupt_result"], |
| 3255 | ); | 3273 | ); |
| 3256 | assert.deepEqual(observed, ["run:interrupted", "pull-requests-refreshed"]); | 3274 | assert.deepEqual(observed, ["run:interrupted", "pull-requests-refreshed"]); |
| 3275 | assert.deepEqual( | ||
| 3276 | committedEffects.map((effect) => effect.request.type), | ||
| 3277 | ["checkpoint.capture"], | ||
| 3278 | ); | ||
| 3257 | }), | 3279 | }), |
| 3258 | ); | 3280 | ); |
| 3259 | 3281 | ||
| ... | @@ -3382,6 +3404,7 @@ it.effect("keeps completed runs completed when pull request refresh fails", () = | ... | @@ -3382,6 +3404,7 @@ it.effect("keeps completed runs completed when pull request refresh fails", () = |
| 3382 | function captureRootRunTermination(input: { | 3404 | function captureRootRunTermination(input: { |
| 3383 | readonly key: string; | 3405 | readonly key: string; |
| 3384 | readonly shouldFinalizeRun: () => Effect.Effect<boolean, ProjectionStore.ProjectionStoreV2Error>; | 3406 | readonly shouldFinalizeRun: () => Effect.Effect<boolean, ProjectionStore.ProjectionStoreV2Error>; |
| 3407 | readonly rejectTerminalWrite?: boolean; | ||
| 3385 | readonly hasUnpairedRunInterruptRequest?: () => Effect.Effect<boolean, never>; | 3408 | readonly hasUnpairedRunInterruptRequest?: () => Effect.Effect<boolean, never>; |
| 3386 | readonly seedOpenSubagent?: boolean; | 3409 | readonly seedOpenSubagent?: boolean; |
| 3387 | readonly events?: ( | 3410 | readonly events?: ( |
| ... | @@ -3402,6 +3425,8 @@ function captureRootRunTermination(input: { | ... | @@ -3402,6 +3425,8 @@ function captureRootRunTermination(input: { |
| 3402 | }); | 3425 | }); |
| 3403 | const writtenItems = yield* Ref.make<ReadonlyArray<OrchestrationV2TurnItem>>([]); | 3426 | const writtenItems = yield* Ref.make<ReadonlyArray<OrchestrationV2TurnItem>>([]); |
| 3404 | const observed = yield* Ref.make<ReadonlyArray<string>>([]); | 3427 | const observed = yield* Ref.make<ReadonlyArray<string>>([]); |
| 3428 | const submittedEffects = yield* Ref.make<ReadonlyArray<PendingOrchestrationEffectV2>>([]); | ||
| 3429 | const committedEffects = yield* Ref.make<ReadonlyArray<PendingOrchestrationEffectV2>>([]); | ||
| 3405 | const ingestionDone = yield* Deferred.make<void>(); | 3430 | const ingestionDone = yield* Deferred.make<void>(); |
| 3406 | const captureTurnItem = (payload: OrchestrationV2TurnItem) => | 3431 | const captureTurnItem = (payload: OrchestrationV2TurnItem) => |
| 3407 | Ref.update(writtenItems, (current) => [...current, payload]); | 3432 | Ref.update(writtenItems, (current) => [...current, payload]); |
| ... | @@ -3430,11 +3455,24 @@ function captureRootRunTermination(input: { | ... | @@ -3430,11 +3455,24 @@ function captureRootRunTermination(input: { |
| 3430 | } | 3455 | } |
| 3431 | return []; | 3456 | return []; |
| 3432 | }), | 3457 | }), |
| 3433 | writeWithEffects: (payload) => captureFinalEvents(payload.events).pipe(Effect.as([])), | 3458 | writeWithEffects: (payload) => |
| 3459 | Effect.gen(function* () { | ||
| 3460 | yield* Ref.update(submittedEffects, (current) => [...current, ...payload.effects]); | ||
| 3461 | yield* Ref.update(committedEffects, (current) => [...current, ...payload.effects]); | ||
| 3462 | yield* captureFinalEvents(payload.events); | ||
| 3463 | return []; | ||
| 3464 | }), | ||
| 3434 | writeIfRunCurrent: (payload) => | 3465 | writeIfRunCurrent: (payload) => |
| 3435 | captureFinalEvents(payload.events).pipe( | 3466 | Effect.gen(function* () { |
| 3436 | Effect.as({ committed: true, storedEvents: [] }), | 3467 | const effects = payload.effects ?? []; |
| 3437 | ), | 3468 | yield* Ref.update(submittedEffects, (current) => [...current, ...effects]); |
| 3469 | if (input.rejectTerminalWrite === true) { | ||
| 3470 | return { committed: false, storedEvents: [] }; | ||
| 3471 | } | ||
| 3472 | yield* Ref.update(committedEffects, (current) => [...current, ...effects]); | ||
| 3473 | yield* captureFinalEvents(payload.events); | ||
| 3474 | return { committed: true, storedEvents: [] }; | ||
| 3475 | }), | ||
| 3438 | }), | 3476 | }), |
| 3439 | IdAllocator.layer, | 3477 | IdAllocator.layer, |
| 3440 | Layer.mock(ProviderEventIngestor.ProviderEventIngestorV2)({ | 3478 | Layer.mock(ProviderEventIngestor.ProviderEventIngestorV2)({ |
| ... | @@ -3543,7 +3581,12 @@ function captureRootRunTermination(input: { | ... | @@ -3543,7 +3581,12 @@ function captureRootRunTermination(input: { |
| 3543 | }).pipe(Effect.provide(layerTest)); | 3581 | }).pipe(Effect.provide(layerTest)); |
| 3544 | 3582 | ||
| 3545 | yield* Deferred.await(ingestionDone); | 3583 | yield* Deferred.await(ingestionDone); |
| 3546 | return { written: yield* Ref.get(writtenItems), observed: yield* Ref.get(observed) }; | 3584 | return { |
| 3585 | written: yield* Ref.get(writtenItems), | ||
| 3586 | observed: yield* Ref.get(observed), | ||
| 3587 | submittedEffects: yield* Ref.get(submittedEffects), | ||
| 3588 | committedEffects: yield* Ref.get(committedEffects), | ||
| 3589 | }; | ||
| 3547 | }); | 3590 | }); |
| 3548 | } | 3591 | } |
| 3549 | 3592 |
apps/server/src/orchestration-v2/RunExecutionService.ts+11-2| ... | @@ -783,6 +783,7 @@ export const layer: Layer.Layer< | ... | @@ -783,6 +783,7 @@ export const layer: Layer.Layer< |
| 783 | activeAttemptId: input.writeIfRunCurrent.activeAttemptId, | 783 | activeAttemptId: input.writeIfRunCurrent.activeAttemptId, |
| 784 | expectedStatus: input.writeIfRunCurrent.expectedStatus, | 784 | expectedStatus: input.writeIfRunCurrent.expectedStatus, |
| 785 | events: finalization.events, | 785 | events: finalization.events, |
| 786 | effects: finalization.effects, | ||
| 786 | }); | 787 | }); |
| 787 | if (!result.committed) { | 788 | if (!result.committed) { |
| 788 | return; | 789 | return; |
| ... | @@ -971,7 +972,15 @@ export const layer: Layer.Layer< | ... | @@ -971,7 +972,15 @@ export const layer: Layer.Layer< |
| 971 | attempt: input.attempt, | 972 | attempt: input.attempt, |
| 972 | ...(input.shouldFinalizeRun === undefined | 973 | ...(input.shouldFinalizeRun === undefined |
| 973 | ? {} | 974 | ? {} |
| 974 | : { shouldFinalizeRun: input.shouldFinalizeRun }), | 975 | : { |
| 976 | shouldFinalizeRun: input.shouldFinalizeRun, | ||
| 977 | // Stop may commit between the ownership read and this | ||
| 978 | // terminal write. Gate the events and checkpoint together. | ||
| 979 | writeIfRunCurrent: { | ||
| 980 | activeAttemptId: input.attempt.id, | ||
| 981 | expectedStatus: "running" as const, | ||
| 982 | }, | ||
| 983 | }), | ||
| 975 | ...(input.hasUnpairedRunInterruptRequest === undefined | 984 | ...(input.hasUnpairedRunInterruptRequest === undefined |
| 976 | ? {} | 985 | ? {} |
| 977 | : { | 986 | : { |
| ... | @@ -1437,7 +1446,7 @@ export const layer: Layer.Layer< | ... | @@ -1437,7 +1446,7 @@ export const layer: Layer.Layer< |
| 1437 | }), | 1446 | }), |
| 1438 | ); | 1447 | ); |
| 1439 | 1448 | ||
| 1440 | function makeInterruptResultTurnItem(input: { | 1449 | export function makeInterruptResultTurnItem(input: { |
| 1441 | readonly idAllocator: IdAllocator.IdAllocatorV2Shape; | 1450 | readonly idAllocator: IdAllocator.IdAllocatorV2Shape; |
| 1442 | readonly run: OrchestrationV2Run; | 1451 | readonly run: OrchestrationV2Run; |
| 1443 | readonly rootNode: OrchestrationV2ExecutionNode; | 1452 | readonly rootNode: OrchestrationV2ExecutionNode; |
apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts+5-1| ... | @@ -261,7 +261,10 @@ export function layerWithRegistry<Error>( | ... | @@ -261,7 +261,10 @@ export function layerWithRegistry<Error>( |
| 261 | readonly continueThreadsAfterServerUpdate?: boolean; | 261 | readonly continueThreadsAfterServerUpdate?: boolean; |
| 262 | } = {}, | 262 | } = {}, |
| 263 | ): Layer.Layer< | 263 | ): Layer.Layer< |
| 264 | Orchestrator.OrchestratorV2 | EffectWorker.OrchestrationEffectWorkerV2 | EventSink.EventSinkV2, | 264 | | Orchestrator.OrchestratorV2 |
| 265 | | EffectWorker.OrchestrationEffectWorkerV2 | ||
| 266 | | EventSink.EventSinkV2 | ||
| 267 | | ProviderSessionManager.ProviderSessionManagerV2, | ||
| 265 | Error | MigrationError | PlatformError.PlatformError | SqlError | 268 | Error | MigrationError | PlatformError.PlatformError | SqlError |
| 266 | > { | 269 | > { |
| 267 | const layerServerConfig = Layer.effect( | 270 | const layerServerConfig = Layer.effect( |
| ... | @@ -474,6 +477,7 @@ export function layerWithRegistry<Error>( | ... | @@ -474,6 +477,7 @@ export function layerWithRegistry<Error>( |
| 474 | ); | 477 | ); |
| 475 | const layerReplayRuntime = Layer.mergeAll( | 478 | const layerReplayRuntime = Layer.mergeAll( |
| 476 | layerOrchestratorProvided, | 479 | layerOrchestratorProvided, |
| 480 | layerProviderSessionManagerProvided, | ||
| 477 | layerEffectWorkerProvided, | 481 | layerEffectWorkerProvided, |
| 478 | layerEventSinkProvided, | 482 | layerEventSinkProvided, |
| 479 | layerContinuationWorkerProvided, | 483 | layerContinuationWorkerProvided, |