| ... | @@ -68,10 +68,8 @@ function isStaleRequestFailureDetail(payload: Record<string, unknown> | null): b | ... | @@ -68,10 +68,8 @@ function isStaleRequestFailureDetail(payload: Record<string, unknown> | null): b |
| 68 | // Scans the read model's activities, which the projector caps at the most | 68 | // Scans the read model's activities, which the projector caps at the most |
| 69 | // recent 500 plus pending async questions. Async questions remain actionable | 69 | // recent 500 plus pending async questions. Async questions remain actionable |
| 70 | // while the agent works, so they must not expire with the activity window. | 70 | // while the agent works, so they must not expire with the activity window. |
| 71 | function hasOpenBlockingRequest(thread: { | 71 | function openRequests(thread: Pick<OrchestrationThread, "activities">) { |
| 72 | readonly activities: ReadonlyArray<{ readonly kind: string; readonly payload: unknown }>; | 72 | const requests = new Map<string, OrchestrationThreadActivity>(); |
| 73 | }): boolean { | | |
| 74 | const openRequestIds = new Set<string>(); | | |
| 75 | for (const activity of thread.activities) { | 73 | for (const activity of thread.activities) { |
| 76 | const payload = | 74 | const payload = |
| 77 | typeof activity.payload === "object" && activity.payload !== null | 75 | typeof activity.payload === "object" && activity.payload !== null |
| ... | @@ -80,18 +78,18 @@ function hasOpenBlockingRequest(thread: { | ... | @@ -80,18 +78,18 @@ function hasOpenBlockingRequest(thread: { |
| 80 | const requestId = typeof payload?.requestId === "string" ? payload.requestId : null; | 78 | const requestId = typeof payload?.requestId === "string" ? payload.requestId : null; |
| 81 | if (requestId === null) continue; | 79 | if (requestId === null) continue; |
| 82 | if (activity.kind === "approval.requested" || activity.kind === "user-input.requested") { | 80 | if (activity.kind === "approval.requested" || activity.kind === "user-input.requested") { |
| 83 | openRequestIds.add(requestId); | 81 | requests.set(requestId, activity); |
| 84 | } else if (activity.kind === "approval.resolved" || activity.kind === "user-input.resolved") { | 82 | } else if (activity.kind === "approval.resolved" || activity.kind === "user-input.resolved") { |
| 85 | openRequestIds.delete(requestId); | 83 | requests.delete(requestId); |
| 86 | } else if ( | 84 | } else if ( |
| 87 | (activity.kind === "provider.approval.respond.failed" || | 85 | (activity.kind === "provider.approval.respond.failed" || |
| 88 | activity.kind === "provider.user-input.respond.failed") && | 86 | activity.kind === "provider.user-input.respond.failed") && |
| 89 | isStaleRequestFailureDetail(payload) | 87 | isStaleRequestFailureDetail(payload) |
| 90 | ) { | 88 | ) { |
| 91 | openRequestIds.delete(requestId); | 89 | requests.delete(requestId); |
| 92 | } | 90 | } |
| 93 | } | 91 | } |
| 94 | return openRequestIds.size > 0; | 92 | return requests; |
| 95 | } | 93 | } |
| 96 | | 94 | |
| 97 | /** Apply the shared shell-level rule to the detailed command read model. */ | 95 | /** Apply the shared shell-level rule to the detailed command read model. */ |
| ... | @@ -456,10 +454,18 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" | ... | @@ -456,10 +454,18 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 456 | if (thread.session?.status === "starting" || thread.session?.status === "running") { | 454 | if (thread.session?.status === "starting" || thread.session?.status === "running") { |
| 457 | return yield* new OrchestrationThreadSettleBlockedError({ threadId: command.threadId }); | 455 | return yield* new OrchestrationThreadSettleBlockedError({ threadId: command.threadId }); |
| 458 | } | 456 | } |
| 459 | // Pending approval / user-input requests are blocked-on-you work: a | 457 | const pendingRequests = openRequests(thread); |
| 460 | // raced or stale client must not park them behind a settled override | 458 | // Manual settlement dismisses async questions without answering them. |
| 461 | // that would surface only after the request resolves. | 459 | // Native callbacks and approvals still need a response or interruption. |
| 462 | if (hasOpenBlockingRequest(thread)) { | 460 | if ( |
| | 461 | Array.from(pendingRequests.values()).some( |
| | 462 | (activity) => |
| | 463 | command.type === "thread.auto-settle" || |
| | 464 | activity.kind !== "user-input.requested" || |
| | 465 | !Predicate.isObject(activity.payload) || |
| | 466 | activity.payload.responseMode !== "message", |
| | 467 | ) |
| | 468 | ) { |
| 463 | return yield* new OrchestrationThreadSettleBlockedError({ threadId: command.threadId }); | 469 | return yield* new OrchestrationThreadSettleBlockedError({ threadId: command.threadId }); |
| 464 | } | 470 | } |
| 465 | const occurredAt = yield* nowIso; | 471 | const occurredAt = yield* nowIso; |
| ... | @@ -495,6 +501,29 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" | ... | @@ -495,6 +501,29 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 495 | // Settling is "I'm done with this": clear states that would keep the | 501 | // Settling is "I'm done with this": clear states that would keep the |
| 496 | // row pinned or snoozed instead of showing the new settled state. | 502 | // row pinned or snoozed instead of showing the new settled state. |
| 497 | const companionEvents: Array<Omit<OrchestrationEvent, "sequence">> = []; | 503 | const companionEvents: Array<Omit<OrchestrationEvent, "sequence">> = []; |
| | 504 | for (const [requestId, request] of pendingRequests) { |
| | 505 | companionEvents.push({ |
| | 506 | ...(yield* withEventBase({ |
| | 507 | aggregateKind: "thread", |
| | 508 | aggregateId: command.threadId, |
| | 509 | occurredAt, |
| | 510 | commandId: command.commandId, |
| | 511 | })), |
| | 512 | type: "thread.activity-appended", |
| | 513 | payload: { |
| | 514 | threadId: command.threadId, |
| | 515 | activity: { |
| | 516 | id: EventId.make(`settle:${command.commandId}:${requestId}`), |
| | 517 | kind: "user-input.resolved", |
| | 518 | summary: "User input dismissed", |
| | 519 | tone: "info", |
| | 520 | turnId: request.turnId, |
| | 521 | createdAt: occurredAt, |
| | 522 | payload: { requestId, responseMode: "message" }, |
| | 523 | }, |
| | 524 | }, |
| | 525 | }); |
| | 526 | } |
| 498 | if (thread.pinnedAt != null) { | 527 | if (thread.pinnedAt != null) { |
| 499 | companionEvents.push({ | 528 | companionEvents.push({ |
| 500 | ...(yield* withEventBase({ | 529 | ...(yield* withEventBase({ |
| ... | @@ -581,7 +610,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" | ... | @@ -581,7 +610,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 581 | // user-input request is the agent waiting on the user, and hiding it | 610 | // user-input request is the agent waiting on the user, and hiding it |
| 582 | // defeats the request. (A running session IS snoozable — snooze only | 611 | // defeats the request. (A running session IS snoozable — snooze only |
| 583 | // affects visibility, never the agent.) | 612 | // affects visibility, never the agent.) |
| 584 | if (hasOpenBlockingRequest(thread)) { | 613 | if (openRequests(thread).size > 0) { |
| 585 | return yield* Effect.fail( | 614 | return yield* Effect.fail( |
| 586 | new OrchestrationCommandInvariantError({ | 615 | new OrchestrationCommandInvariantError({ |
| 587 | commandType: command.type, | 616 | commandType: command.type, |
| ... | @@ -1453,7 +1482,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" | ... | @@ -1453,7 +1482,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 1453 | thread.messages.length > 0 || | 1482 | thread.messages.length > 0 || |
| 1454 | thread.latestTurn !== null || | 1483 | thread.latestTurn !== null || |
| 1455 | thread.session !== null || | 1484 | thread.session !== null || |
| 1456 | hasOpenBlockingRequest(thread) | 1485 | openRequests(thread).size > 0 |
| 1457 | ) { | 1486 | ) { |
| 1458 | return yield* new OrchestrationCommandInvariantError({ | 1487 | return yield* new OrchestrationCommandInvariantError({ |
| 1459 | commandType: command.type, | 1488 | commandType: command.type, |