| author | |
| committer | |
| log | 737192594f47d87f28b5f5a3fda3ef792a0d7972 |
| tree | 3cc11d4ea8f384a2861c7f71216d249316722a7b |
| parent | f37f99ca9e04dd0c4d0ee814402ae224175adc8d |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>4 files changed, 88 insertions(+), 0 deletions(-)
apps/server/src/orchestration-v2/ProjectionStore.ts+24| ... | @@ -73,6 +73,7 @@ import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2Pend | ... | @@ -73,6 +73,7 @@ import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2Pend |
| 73 | import * as Context from "effect/Context"; | 73 | import * as Context from "effect/Context"; |
| 74 | import * as DateTime from "effect/DateTime"; | 74 | import * as DateTime from "effect/DateTime"; |
| 75 | import * as Effect from "effect/Effect"; | 75 | import * as Effect from "effect/Effect"; |
| 76 | import * as Equal from "effect/Equal"; | ||
| 76 | import * as Layer from "effect/Layer"; | 77 | import * as Layer from "effect/Layer"; |
| 77 | import * as Ref from "effect/Ref"; | 78 | import * as Ref from "effect/Ref"; |
| 78 | import * as Schema from "effect/Schema"; | 79 | import * as Schema from "effect/Schema"; |
| ... | @@ -460,6 +461,10 @@ export interface ProjectionStoreV2Shape { | ... | @@ -460,6 +461,10 @@ export interface ProjectionStoreV2Shape { |
| 460 | requestId: TurnItemId, | 461 | requestId: TurnItemId, |
| 461 | resultId: TurnItemId, | 462 | resultId: TurnItemId, |
| 462 | ) => Effect.Effect<boolean, ProjectionStoreV2Error>; | 463 | ) => Effect.Effect<boolean, ProjectionStoreV2Error>; |
| 464 | /** True when the projection already stores this exact node, so a write would change nothing. */ | ||
| 465 | readonly isNodeStored: ( | ||
| 466 | node: OrchestrationV2ExecutionNode, | ||
| 467 | ) => Effect.Effect<boolean, ProjectionStoreV2Error>; | ||
| 463 | readonly getRuntimeRequest: ( | 468 | readonly getRuntimeRequest: ( |
| 464 | threadId: ThreadId, | 469 | threadId: ThreadId, |
| 465 | requestId: RuntimeRequestId, | 470 | requestId: RuntimeRequestId, |
| ... | @@ -4404,6 +4409,15 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = | ... | @@ -4404,6 +4409,15 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = |
| 4404 | return rows[0]?.pending === 1; | 4409 | return rows[0]?.pending === 1; |
| 4405 | }).pipe(Effect.mapError(controlReadError(threadId))); | 4410 | }).pipe(Effect.mapError(controlReadError(threadId))); |
| 4406 | 4411 | ||
| 4412 | const isNodeStored: ProjectionStoreV2Shape["isNodeStored"] = (node) => | ||
| 4413 | Effect.gen(function* () { | ||
| 4414 | const payloadJson = yield* encodeNodePayload(node); | ||
| 4415 | const rows = yield* sql<PayloadRow>` | ||
| 4416 | SELECT payload_json FROM orchestration_v2_projection_nodes WHERE node_id = ${node.id} | ||
| 4417 | `; | ||
| 4418 | return rows[0]?.payload_json === payloadJson; | ||
| 4419 | }).pipe(Effect.mapError(controlReadError(node.threadId))); | ||
| 4420 | |||
| 4407 | const getRuntimeRequest: ProjectionStoreV2Shape["getRuntimeRequest"] = (threadId, requestId) => | 4421 | const getRuntimeRequest: ProjectionStoreV2Shape["getRuntimeRequest"] = (threadId, requestId) => |
| 4408 | sql | 4422 | sql |
| 4409 | .withTransaction( | 4423 | .withTransaction( |
| ... | @@ -5992,6 +6006,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = | ... | @@ -5992,6 +6006,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = |
| 5992 | canStartQueuedRun, | 6006 | canStartQueuedRun, |
| 5993 | getPendingNativeUserInputs, | 6007 | getPendingNativeUserInputs, |
| 5994 | hasUnpairedRunInterruptRequest, | 6008 | hasUnpairedRunInterruptRequest, |
| 6009 | isNodeStored, | ||
| 5995 | getMessageCount, | 6010 | getMessageCount, |
| 5996 | getNextTurnItemOrdinal, | 6011 | getNextTurnItemOrdinal, |
| 5997 | getTurnItem, | 6012 | getTurnItem, |
| ... | @@ -6255,6 +6270,15 @@ export const layerMemory: Layer.Layer<ProjectionStoreV2> = Layer.effect( | ... | @@ -6255,6 +6270,15 @@ export const layerMemory: Layer.Layer<ProjectionStoreV2> = Layer.effect( |
| 6255 | !projection.turnItems.some((item) => item.id === resultId) | 6270 | !projection.turnItems.some((item) => item.id === resultId) |
| 6256 | ); | 6271 | ); |
| 6257 | }), | 6272 | }), |
| 6273 | isNodeStored: (node) => | ||
| 6274 | Ref.get(replayState).pipe( | ||
| 6275 | Effect.map((state) => { | ||
| 6276 | const stored = state.projections | ||
| 6277 | .get(node.threadId) | ||
| 6278 | ?.nodes.find((candidate) => candidate.id === node.id); | ||
| 6279 | return stored !== undefined && Equal.equals(stored, node); | ||
| 6280 | }), | ||
| 6281 | ), | ||
| 6258 | getMessageCount: (threadId) => | 6282 | getMessageCount: (threadId) => |
| 6259 | Ref.get(replayState).pipe( | 6283 | Ref.get(replayState).pipe( |
| 6260 | Effect.map((state) => state.projections.get(threadId)?.messages.length ?? 0), | 6284 | Effect.map((state) => state.projections.get(threadId)?.messages.length ?? 0), |
apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts+60| ... | @@ -325,6 +325,66 @@ layer("ProviderEventIngestorV2", (it) => { | ... | @@ -325,6 +325,66 @@ layer("ProviderEventIngestorV2", (it) => { |
| 325 | }), | 325 | }), |
| 326 | ); | 326 | ); |
| 327 | 327 | ||
| 328 | it.effect("skips a node update only when it matches the stored node", () => | ||
| 329 | Effect.gen(function* () { | ||
| 330 | const now = yield* DateTime.now; | ||
| 331 | const eventSink = yield* EventSink.EventSinkV2; | ||
| 332 | const projectionStore = yield* ProjectionStore.ProjectionStoreV2; | ||
| 333 | const ingestor = yield* ProviderEventIngestor.ProviderEventIngestorV2; | ||
| 334 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | ||
| 335 | const threadEvent = yield* threadCreatedEvent(now); | ||
| 336 | const threadId = threadEvent.threadId; | ||
| 337 | yield* eventSink.write({ events: [threadEvent] }); | ||
| 338 | const providerSessionId = yield* idAllocator.allocate.providerSession({ | ||
| 339 | providerInstanceId: modelSelection.instanceId, | ||
| 340 | threadId, | ||
| 341 | }); | ||
| 342 | const nodeId = NodeId.make(`${threadId}:approval`); | ||
| 343 | const node: OrchestrationV2ExecutionNode = { | ||
| 344 | id: nodeId, | ||
| 345 | threadId, | ||
| 346 | runId: null, | ||
| 347 | parentNodeId: null, | ||
| 348 | rootNodeId: nodeId, | ||
| 349 | kind: "approval_request", | ||
| 350 | status: "waiting", | ||
| 351 | countsForRun: false, | ||
| 352 | providerThreadId: null, | ||
| 353 | providerTurnId: null, | ||
| 354 | nativeItemRef: null, | ||
| 355 | runtimeRequestId: null, | ||
| 356 | checkpointScopeId: null, | ||
| 357 | startedAt: now, | ||
| 358 | completedAt: null, | ||
| 359 | }; | ||
| 360 | const ingestNode = (sent: OrchestrationV2ExecutionNode) => | ||
| 361 | ingestor.ingestNormalized({ | ||
| 362 | providerSessionId, | ||
| 363 | providerInstanceId: modelSelection.instanceId, | ||
| 364 | threadId, | ||
| 365 | event: { type: "node.updated", driver: CODEX_DRIVER, node: sent }, | ||
| 366 | }); | ||
| 367 | |||
| 368 | assert.lengthOf(yield* ingestNode(node), 1); | ||
| 369 | assert.isEmpty(yield* ingestNode({ ...node })); | ||
| 370 | // The server resolves the request, then the provider asks again with the same node. | ||
| 371 | yield* eventSink.write({ | ||
| 372 | events: [ | ||
| 373 | { | ||
| 374 | id: yield* idAllocator.allocate.event({ threadId }), | ||
| 375 | type: "node.updated", | ||
| 376 | threadId, | ||
| 377 | occurredAt: now, | ||
| 378 | payload: { ...node, status: "completed", completedAt: now }, | ||
| 379 | }, | ||
| 380 | ], | ||
| 381 | }); | ||
| 382 | assert.lengthOf(yield* ingestNode(node), 1); | ||
| 383 | const projection = yield* projectionStore.getThreadProjection(threadId); | ||
| 384 | assert.equal(projection.nodes.find((entry) => entry.id === nodeId)?.status, "waiting"); | ||
| 385 | }), | ||
| 386 | ); | ||
| 387 | |||
| 328 | it.effect("rolls back runs a provider rewound off its active branch", () => | 388 | it.effect("rolls back runs a provider rewound off its active branch", () => |
| 329 | Effect.gen(function* () { | 389 | Effect.gen(function* () { |
| 330 | const now = yield* DateTime.now; | 390 | const now = yield* DateTime.now; |
apps/server/src/orchestration-v2/ProviderEventIngestor.ts+3| ... | @@ -611,6 +611,9 @@ export const layer: Layer.Layer< | ... | @@ -611,6 +611,9 @@ export const layer: Layer.Layer< |
| 611 | }), | 611 | }), |
| 612 | ]; | 612 | ]; |
| 613 | case "node.updated": | 613 | case "node.updated": |
| 614 | // Adapters send an unchanged node again with each text delta. Compare with the | ||
| 615 | // stored node, not the last one sent, because the server also writes nodes. | ||
| 616 | if (yield* projections.isNodeStored(input.event.node)) return []; | ||
| 614 | return [ | 617 | return [ |
| 615 | yield* makeDomainEvent(input, { | 618 | yield* makeDomainEvent(input, { |
| 616 | type: "node.updated", | 619 | type: "node.updated", |
apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts+1| ... | @@ -255,6 +255,7 @@ it.effect( | ... | @@ -255,6 +255,7 @@ it.effect( |
| 255 | getCheckpointContext: () => Effect.die("not used"), | 255 | getCheckpointContext: () => Effect.die("not used"), |
| 256 | getCheckpointCaptureContext: () => Effect.die("not used"), | 256 | getCheckpointCaptureContext: () => Effect.die("not used"), |
| 257 | getRunMessage: () => Effect.die("not used"), | 257 | getRunMessage: () => Effect.die("not used"), |
| 258 | isNodeStored: () => Effect.die("not used"), | ||
| 258 | canStartQueuedRun: () => Effect.die("not used"), | 259 | canStartQueuedRun: () => Effect.die("not used"), |
| 259 | getRecoveryThreadIds: () => Effect.die("unused getRecoveryThreadIds"), | 260 | getRecoveryThreadIds: () => Effect.die("unused getRecoveryThreadIds"), |
| 260 | getUnreadableThreadIds: () => Effect.die("unused getUnreadableThreadIds"), | 261 | getUnreadableThreadIds: () => Effect.die("unused getUnreadableThreadIds"), |