| 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 | 31 | ProviderAdapterV2TurnInput, |
| 32 | 32 | } from "./ProviderAdapter.ts"; |
| 33 | 33 | import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; |
| 34 | import * as ProviderSessionManager from "./ProviderSessionManager.ts"; | |
| 34 | 35 | import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; |
| 35 | 36 | import { checkpointWorkspace } from "./testkit/ReplayFixtureWorkspace.ts"; |
| 36 | 37 | |
| ... | ... | @@ -45,10 +46,17 @@ const stopEarlierBackgroundWork = ({ |
| 45 | 46 | failedStart = false, |
| 46 | 47 | stopWithQueue, |
| 47 | 48 | olderStart = false, |
| 49 | stalledRun, | |
| 48 | 50 | }: { |
| 49 | 51 | readonly failedStart?: boolean; |
| 50 | 52 | readonly stopWithQueue?: "thread.stop" | "run.interrupt"; |
| 51 | 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 | 61 | Effect.scoped( |
| 54 | 62 | Effect.gen(function* () { |
| ... | ... | @@ -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 | 396 | const settled = yield* watch( |
| 214 | 397 | (event) => |
| 215 | 398 | event.type === "run.updated" && |
| ... | ... | @@ -599,3 +782,13 @@ it.effect.each(["thread.stop", "run.interrupt"] as const)( |
| 599 | 782 | "%s reaches later background work when an older run is starting", |
| 600 | 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 | 81 | readonly failFirstStart?: Ref.Ref<boolean>; |
| 82 | 82 | readonly threads?: Partial<ThreadManagementService.ThreadManagementService["Service"]>; |
| 83 | 83 | readonly continueAfterRestart?: boolean; |
| 84 | readonly interrupt?: ProviderTurnControlService.ProviderTurnControlServiceV2Shape["interrupt"]; | |
| 84 | 85 | }) { |
| 85 | 86 | const record = (event: string) => Ref.update(input.events, (events) => [...events, event]); |
| 86 | 87 | const layerDependencies = Layer.mergeAll( |
| 87 | 88 | Layer.succeed( |
| 88 | 89 | ProviderTurnControlService.ProviderTurnControlServiceV2, |
| 89 | 90 | ProviderTurnControlService.ProviderTurnControlServiceV2.of({ |
| 90 | interrupt: () => Effect.void, | |
| 91 | interrupt: input.interrupt ?? (() => Effect.void), | |
| 91 | 92 | steer: () => Effect.void, |
| 92 | 93 | interruptAndAwaitTerminal: (request) => |
| 93 | 94 | record( |
| ... | ... | @@ -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 | 233 | it.effect("requeues a claim when a pre-execution worker check fails", () => |
| 197 | 234 | Effect.gen(function* () { |
| 198 | 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 | 171 | providerTurnId: effect.request.providerTurnId, |
| 172 | 172 | }) |
| 173 | 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 | 182 | // The provider has stopped what it still ran and reported it. |
| 175 | 183 | // Whatever the thread still shows on that provider thread is |
| 176 | 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 | 90 | readonly activeAttemptId: RunAttemptId; |
| 91 | 91 | readonly expectedStatus: OrchestrationV2Run["status"]; |
| 92 | 92 | readonly events: ReadonlyArray<OrchestrationV2DomainEvent>; |
| 93 | readonly effects?: ReadonlyArray<EffectOutbox.PendingOrchestrationEffectV2>; | |
| 93 | 94 | }) => Effect.Effect< |
| 94 | 95 | { |
| 95 | 96 | readonly committed: boolean; |
| ... | ... | @@ -437,9 +438,17 @@ const layerBase: Layer.Layer< |
| 437 | 438 | events: normalized, |
| 438 | 439 | }); |
| 439 | 440 | yield* applyStoredEvents(storedEvents); |
| 441 | yield* effectOutbox.enqueue(input.effects ?? []); | |
| 440 | 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 | 1525 | Effect.gen(function* () { |
| 1526 | 1526 | const eventSink = yield* EventSink.EventSinkV2; |
| 1527 | 1527 | const projectionStore = yield* ProjectionStore.ProjectionStoreV2; |
| 1528 | const outbox = yield* EffectOutbox.EffectOutboxV2; | |
| 1528 | 1529 | const now = yield* DateTime.now; |
| 1529 | 1530 | const threadId = ThreadId.make("thread:foundation-stale-provider-start"); |
| 1530 | 1531 | const runId = RunId.make("run:foundation-stale-provider-start"); |
| ... | ... | @@ -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 | 1595 | const reachedPrecommitGap = yield* Deferred.make<void>(); |
| 1571 | 1596 | const releaseStaleStart = yield* Deferred.make<void>(); |
| 1572 | 1597 | const providerStartCount = yield* Ref.make(0); |
| ... | ... | @@ -1578,6 +1603,7 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => { |
| 1578 | 1603 | runId, |
| 1579 | 1604 | activeAttemptId: attemptId, |
| 1580 | 1605 | expectedStatus: "starting", |
| 1606 | effects: [staleCaptureEffect], | |
| 1581 | 1607 | events: [ |
| 1582 | 1608 | { |
| 1583 | 1609 | id: EventId.make("event:foundation-stale-provider-start:running"), |
| ... | ... | @@ -1619,11 +1645,12 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => { |
| 1619 | 1645 | |
| 1620 | 1646 | const staleResult = yield* Fiber.join(staleStartFiber); |
| 1621 | 1647 | assert.isFalse(staleResult.committed); |
| 1648 | assert.isTrue(Option.isNone(yield* outbox.get(staleCaptureEffect.id))); | |
| 1622 | 1649 | assert.deepEqual(staleResult.storedEvents, []); |
| 1623 | 1650 | assert.equal(yield* Ref.get(providerStartCount), 0); |
| 1624 | 1651 | const projection = yield* projectionStore.getThreadProjection(threadId); |
| 1625 | 1652 | assert.equal(projection.runs[0]?.status, "cancelled"); |
| 1626 | }), | |
| 1653 | }).pipe(Effect.provide(Layer.fresh(layerTest))), | |
| 1627 | 1654 | ); |
| 1628 | 1655 | |
| 1629 | 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 | 102 | import { ProviderAdapterRegistryV2 } from "./ProviderAdapterRegistry.ts"; |
| 103 | 103 | import { ProviderContinuationRequests } from "./ProviderContinuationRequests.ts"; |
| 104 | 104 | import { makeProviderFailure } from "./ProviderFailure.ts"; |
| 105 | import * as RunExecutionService from "./RunExecutionService.ts"; | |
| 105 | 106 | import { ProviderSessionManagerV2 } from "./ProviderSessionManager.ts"; |
| 106 | 107 | import { ProviderSwitchServiceV2 } from "./ProviderSwitchService.ts"; |
| 107 | 108 | import { isAutomaticCompletionRun, queuedRunsInDeliveryOrder } from "./QueuedRunOrder.ts"; |
| ... | ... | @@ -8116,9 +8117,204 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 8116 | 8117 | driver: providerThread.driver, |
| 8117 | 8118 | providerInstanceId: providerThread.providerInstanceId, |
| 8118 | 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 | 8476 | { readonly type: "thread.background-work.settle" } |
| 8281 | 8477 | >, |
| 8282 | 8478 | events: Ref.Ref<Array<OrchestrationV2DomainEvent>>, |
| 8479 | effects: Ref.Ref<Array<PendingOrchestrationEffectV2>>, | |
| 8283 | 8480 | ) => |
| 8284 | 8481 | Effect.gen(function* () { |
| 8285 | 8482 | const [projection, stopped] = yield* Effect.all([ |
| 8286 | 8483 | projectionStore.getThreadRecords( |
| 8287 | 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 | 8502 | turnItemStatuses: ["pending", "running", "waiting"], |
| 8292 | 8503 | }, |
| 8293 | 8504 | ), |
| ... | ... | @@ -8304,12 +8515,35 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 8304 | 8515 | (attempt) => attempt.id === stopped.providerTurn?.runAttemptId, |
| 8305 | 8516 | )?.runId; |
| 8306 | 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 | 8518 | if ( |
| 8309 | 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 | 8543 | projection.runs.some( |
| 8311 | 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 | 8549 | return; |
| ... | ... | @@ -8320,7 +8554,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 8320 | 8554 | projection, |
| 8321 | 8555 | stoppedProviderThreadId: command.providerThreadId, |
| 8322 | 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 | 8575 | "attempts", |
| 8342 | 8576 | "turnItems", |
| 8343 | 8577 | "subagents", |
| 8578 | "runtimeRequests", | |
| 8344 | 8579 | ], |
| 8345 | 8580 | // Thread-wide, like the Waiting strip: a settled thread's background |
| 8346 | 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 | 8590 | turnItemStatuses: ["pending", "running", "waiting"], |
| 8350 | 8591 | }, |
| 8351 | 8592 | ); |
| ... | ... | @@ -8373,7 +8614,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 8373 | 8614 | projection.providerTurns.findLast( |
| 8374 | 8615 | (candidate) => |
| 8375 | 8616 | candidate.runAttemptId === run?.activeAttemptId && |
| 8376 | (candidate.status === "running" || hasBackgroundWork), | |
| 8617 | (run?.status === "running" || candidate.status === "running" || hasBackgroundWork), | |
| 8377 | 8618 | ) ?? |
| 8378 | 8619 | (hasBackgroundWork |
| 8379 | 8620 | ? projection.providerTurns.findLast( |
| ... | ... | @@ -8623,18 +8864,22 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 8623 | 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 | |
| 8627 | // (released, restarted) and only the projection still shows the work; | |
| 8628 | // the settle follow-up ends whatever no provider reports ending. | |
| 8629 | const settleOnly = | |
| 8630 | providerTurn.status !== "running" && | |
| 8631 | (providerThread.providerSessionId === null || | |
| 8632 | Option.isNone( | |
| 8633 | yield* providerSessions | |
| 8634 | .get(providerThread.providerSessionId) | |
| 8635 | .pipe(Effect.orElseSucceed(() => Option.none())), | |
| 8636 | )); | |
| 8637 | if (settleOnly) { | |
| 8867 | // A dead session cannot report a terminal event for either a running | |
| 8868 | // turn or a settled turn's background work. Stop ends both locally. | |
| 8869 | const sessionOption = | |
| 8870 | providerThread.providerSessionId === null | |
| 8871 | ? Option.none() | |
| 8872 | : yield* providerSessions.get(providerThread.providerSessionId).pipe( | |
| 8873 | Effect.mapError( | |
| 8874 | (cause) => | |
| 8875 | new OrchestratorProviderAdapterError({ | |
| 8876 | commandId: command.commandId, | |
| 8877 | providerInstanceId: run.providerInstanceId, | |
| 8878 | cause, | |
| 8879 | }), | |
| 8880 | ), | |
| 8881 | ); | |
| 8882 | if (Option.isNone(sessionOption)) { | |
| 8638 | 8883 | yield* emitEvent({ |
| 8639 | 8884 | type: "turn-item.updated", |
| 8640 | 8885 | threadId: command.threadId, |
| ... | ... | @@ -8645,6 +8890,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 8645 | 8890 | payload: interruptRequestItem, |
| 8646 | 8891 | }); |
| 8647 | 8892 | yield* stopRemainingWork(); |
| 8893 | yield* settleInterruptedRun({ command, projection, providerTurn, events, effects, now }); | |
| 8648 | 8894 | yield* settleBackgroundWork({ |
| 8649 | 8895 | command, |
| 8650 | 8896 | events, |
| ... | ... | @@ -8664,23 +8910,6 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 8664 | 8910 | }); |
| 8665 | 8911 | } |
| 8666 | 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 | 8913 | yield* enforceCommandPolicy(command)( |
| 8685 | 8914 | commandPolicy.ensureInterrupt({ |
| 8686 | 8915 | commandId: command.commandId, |
| ... | ... | @@ -10079,7 +10308,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 10079 | 10308 | yield* dispatchCheckpointRollbackFail(command, events); |
| 10080 | 10309 | break; |
| 10081 | 10310 | case "thread.background-work.settle": |
| 10082 | yield* dispatchBackgroundWorkSettle(command, events); | |
| 10311 | yield* dispatchBackgroundWorkSettle(command, events, effects); | |
| 10083 | 10312 | break; |
| 10084 | 10313 | case "thread.fork": |
| 10085 | 10314 | yield* dispatchThreadFork(command, events); |
apps/server/src/orchestration-v2/ProviderTurnControlService.ts+14| ... | ... | @@ -12,6 +12,7 @@ import * as Effect from "effect/Effect"; |
| 12 | 12 | import * as Layer from "effect/Layer"; |
| 13 | 13 | import * as Option from "effect/Option"; |
| 14 | 14 | import * as Schema from "effect/Schema"; |
| 15 | import * as NodeTimersPromises from "node:timers/promises"; | |
| 15 | 16 | |
| 16 | 17 | import * as ProjectionStore from "./ProjectionStore.ts"; |
| 17 | 18 | import * as ProviderSessionManager from "./ProviderSessionManager.ts"; |
| ... | ... | @@ -185,6 +186,19 @@ export const layer: Layer.Layer< |
| 185 | 186 | providerTurnId: loaded.providerTurn.id, |
| 186 | 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 | 202 | }).pipe( |
| 189 | 203 | Effect.mapError((cause) => |
| 190 | 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 | 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 | 3264 | it.effect("emits run_interrupt_result when hard-stop finalizes the active attempt", () => |
| 3247 | 3265 | Effect.gen(function* () { |
| 3248 | const { written, observed } = yield* captureRootRunTermination({ | |
| 3266 | const { written, observed, committedEffects } = yield* captureRootRunTermination({ | |
| 3249 | 3267 | key: "hard-stop", |
| 3250 | 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 | 3272 | ["run_interrupt_result"], |
| 3255 | 3273 | ); |
| 3256 | 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 | 3404 | function captureRootRunTermination(input: { |
| 3383 | 3405 | readonly key: string; |
| 3384 | 3406 | readonly shouldFinalizeRun: () => Effect.Effect<boolean, ProjectionStore.ProjectionStoreV2Error>; |
| 3407 | readonly rejectTerminalWrite?: boolean; | |
| 3385 | 3408 | readonly hasUnpairedRunInterruptRequest?: () => Effect.Effect<boolean, never>; |
| 3386 | 3409 | readonly seedOpenSubagent?: boolean; |
| 3387 | 3410 | readonly events?: ( |
| ... | ... | @@ -3402,6 +3425,8 @@ function captureRootRunTermination(input: { |
| 3402 | 3425 | }); |
| 3403 | 3426 | const writtenItems = yield* Ref.make<ReadonlyArray<OrchestrationV2TurnItem>>([]); |
| 3404 | 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 | 3430 | const ingestionDone = yield* Deferred.make<void>(); |
| 3406 | 3431 | const captureTurnItem = (payload: OrchestrationV2TurnItem) => |
| 3407 | 3432 | Ref.update(writtenItems, (current) => [...current, payload]); |
| ... | ... | @@ -3430,11 +3455,24 @@ function captureRootRunTermination(input: { |
| 3430 | 3455 | } |
| 3431 | 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 | 3465 | writeIfRunCurrent: (payload) => |
| 3435 | captureFinalEvents(payload.events).pipe( | |
| 3436 | Effect.as({ committed: true, storedEvents: [] }), | |
| 3437 | ), | |
| 3466 | Effect.gen(function* () { | |
| 3467 | const effects = payload.effects ?? []; | |
| 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 | 3477 | IdAllocator.layer, |
| 3440 | 3478 | Layer.mock(ProviderEventIngestor.ProviderEventIngestorV2)({ |
| ... | ... | @@ -3543,7 +3581,12 @@ function captureRootRunTermination(input: { |
| 3543 | 3581 | }).pipe(Effect.provide(layerTest)); |
| 3544 | 3582 | |
| 3545 | 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 | 783 | activeAttemptId: input.writeIfRunCurrent.activeAttemptId, |
| 784 | 784 | expectedStatus: input.writeIfRunCurrent.expectedStatus, |
| 785 | 785 | events: finalization.events, |
| 786 | effects: finalization.effects, | |
| 786 | 787 | }); |
| 787 | 788 | if (!result.committed) { |
| 788 | 789 | return; |
| ... | ... | @@ -971,7 +972,15 @@ export const layer: Layer.Layer< |
| 971 | 972 | attempt: input.attempt, |
| 972 | 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 | 984 | ...(input.hasUnpairedRunInterruptRequest === undefined |
| 976 | 985 | ? {} |
| 977 | 986 | : { |
| ... | ... | @@ -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 | 1450 | readonly idAllocator: IdAllocator.IdAllocatorV2Shape; |
| 1442 | 1451 | readonly run: OrchestrationV2Run; |
| 1443 | 1452 | readonly rootNode: OrchestrationV2ExecutionNode; |
apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts+5-1| ... | ... | @@ -261,7 +261,10 @@ export function layerWithRegistry<Error>( |
| 261 | 261 | readonly continueThreadsAfterServerUpdate?: boolean; |
| 262 | 262 | } = {}, |
| 263 | 263 | ): Layer.Layer< |
| 264 | Orchestrator.OrchestratorV2 | EffectWorker.OrchestrationEffectWorkerV2 | EventSink.EventSinkV2, | |
| 264 | | Orchestrator.OrchestratorV2 | |
| 265 | | EffectWorker.OrchestrationEffectWorkerV2 | |
| 266 | | EventSink.EventSinkV2 | |
| 267 | | ProviderSessionManager.ProviderSessionManagerV2, | |
| 265 | 268 | Error | MigrationError | PlatformError.PlatformError | SqlError |
| 266 | 269 | > { |
| 267 | 270 | const layerServerConfig = Layer.effect( |
| ... | ... | @@ -474,6 +477,7 @@ export function layerWithRegistry<Error>( |
| 474 | 477 | ); |
| 475 | 478 | const layerReplayRuntime = Layer.mergeAll( |
| 476 | 479 | layerOrchestratorProvided, |
| 480 | layerProviderSessionManagerProvided, | |
| 477 | 481 | layerEffectWorkerProvided, |
| 478 | 482 | layerEventSinkProvided, |
| 479 | 483 | layerContinuationWorkerProvided, |